// // WebPageProxy.swift // Prisme // // Author: Simon-Pierre Boucher // import Foundation import Observation import WebKit /// The only surface through which the rest of the app talks to a web page. /// `WKWebView` itself never escapes `Browser/Engine` (CLAUDE.md §9): chrome /// and tabs read observable state here and issue commands through it. @MainActor @Observable final class WebPageProxy { /// The pooled web view currently rendering this page, if any. /// Set by the engine while the owning tab is on screen. @ObservationIgnored weak var webView: WKWebView? /// Fired by the engine when a main-frame load finishes. The app layer /// hooks history recording here; the engine stays ignorant of it. @ObservationIgnored var onDidFinishLoad: ((URL) -> Void)? /// Fired when the user picks "Sauver l'extrait" in the selection menu. @ObservationIgnored var onExcerptRequested: (() -> Void)? private(set) var url: URL? private(set) var title: String = "" private(set) var estimatedProgress: Double = 0 private(set) var isLoading = false private(set) var canGoBack = false private(set) var canGoForward = false // MARK: Commands func load(_ url: URL) { webView?.load(URLRequest(url: url)) } func goBack() { webView?.goBack() } func goForward() { webView?.goForward() } func reload() { webView?.reload() } func stopLoading() { webView?.stopLoading() } // MARK: Content extraction (Distiller steps 1–2, in-page) enum PageError: LocalizedError { case notAttached case extractionFailed var errorDescription: String? { switch self { case .notAttached: "L'onglet n'est pas affiché." case .extractionFailed: "Le contenu de la page n'a pas pu être lu." } } } /// Runs the injected extractor and returns the typed block list. /// Never returns raw HTML (CLAUDE.md §4). func extractContent() async throws -> ExtractedContent { guard let webView else { throw PageError.notAttached } let result = try await webView.evaluateJavaScript("window.__prismeExtract(400)") guard let json = result as? String, let data = json.data(using: .utf8) else { throw PageError.extractionFailed } return try JSONDecoder().decode(ExtractedContent.self, from: data) } /// The user's current text selection, with source anchor and section /// context. Nil when nothing is selected. func captureSelection() async throws -> SelectionExcerpt? { guard let webView else { throw PageError.notAttached } let result = try await webView.evaluateJavaScript("window.__prismeSelection()") guard let json = result as? String, let data = json.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(SelectionExcerpt.self, from: data) } /// Scrolls the page to the DOM element a digest item was derived from — /// the anchor that keeps generated content honest (§7). func scrollToBlock(domPath: String) { let escaped = domPath.replacingOccurrences(of: "'", with: "") webView?.evaluateJavaScript("window.__prismeScrollTo('\(escaped)')") } // MARK: Engine-side state sync func sync(from webView: WKWebView) { url = webView.url title = webView.title ?? "" estimatedProgress = webView.estimatedProgress isLoading = webView.isLoading canGoBack = webView.canGoBack canGoForward = webView.canGoForward } func resetTransientState() { estimatedProgress = 0 isLoading = false } }