import SwiftUI import WebKit // MARK: - Reading progress persistence enum ReadingProgress { private static let key = "readingProgress" static func get(url: String) -> Double { (UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0 } static func set(url: String, progress: Double) { var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:] if progress >= 0.99 { store.removeValue(forKey: url) // fully read — clean up } else { store[url] = progress } UserDefaults.standard.set(store, forKey: key) } static func all() -> [String: Double] { (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:] } } // MARK: - BrowserState @Observable final class BrowserState: NSObject, WKNavigationDelegate { let webView: WKWebView var isLoading = false var canGoBack = false var canGoForward = false var pageTitle = "" var currentURL: URL? var readingProgress: Double = 0 // Weak wrapper breaks WKUserContentController's strong retain of the handler private final class ScrollHandler: NSObject, WKScriptMessageHandler { weak var state: BrowserState? init(_ state: BrowserState) { self.state = state } func userContentController(_ c: WKUserContentController, didReceive msg: WKScriptMessage) { if let p = msg.body as? Double { state?.readingProgress = p } } } private var scrollHandler: ScrollHandler? init(url: URL) { let config = WKWebViewConfiguration() config.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: config) super.init() let handler = ScrollHandler(self) scrollHandler = handler webView.configuration.userContentController.add(handler, name: "scrollProgress") webView.navigationDelegate = self webView.load(URLRequest(url: url)) } deinit { webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress") } func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { isLoading = true } func webView(_ webView: WKWebView, didCommit _: WKNavigation!) { currentURL = webView.url canGoBack = webView.canGoBack canGoForward = webView.canGoForward } func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { isLoading = false canGoBack = webView.canGoBack canGoForward = webView.canGoForward pageTitle = webView.title ?? "" currentURL = webView.url // Restore persisted progress, then start tracking scroll if let urlStr = webView.url?.absoluteString { let saved = ReadingProgress.get(url: urlStr) if saved > 0 { let pct = saved * 100 webView.evaluateJavaScript("window.scrollTo(0, (document.body.scrollHeight - window.innerHeight) * \(pct) / 100)", completionHandler: nil) } } webView.evaluateJavaScript(scrollTrackingJS, completionHandler: nil) } func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { isLoading = false } func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { isLoading = false } func enterReaderMode(completion: @escaping () -> Void) { webView.evaluateJavaScript(readerModeJS) { _, _ in DispatchQueue.main.async { completion() self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil) } } } func exitReaderMode() { webView.reload() } private var scrollTrackingJS: String { """ (function(){ function report(){ var h=document.body.scrollHeight-window.innerHeight; var p=h>0?window.scrollY/h:0; window.webkit.messageHandlers.scrollProgress.postMessage(Math.min(1,Math.max(0,p))); } window.removeEventListener('scroll',window._marksScrollFn); window._marksScrollFn=report; window.addEventListener('scroll',report,{passive:true}); report(); })(); """ } private var readerModeJS: String { """ (function() { var title = document.title; var selectors = ['article','[role="article"]','main','[role="main"]', '.post-content','.article-content','.entry-content', '.post-body','.article-body','.story-body','.content','#content']; var content = null; for (var i = 0; i < selectors.length; i++) { var el = document.querySelector(selectors[i]); if (el && (el.innerText||'').trim().length > 300) { content = el; break; } } if (!content) { var best = { el: null, len: 0 }; Array.from(document.querySelectorAll('div')).forEach(function(d) { var len = Array.from(d.querySelectorAll('p')).reduce(function(a,p){return a+(p.innerText||'').length;},0); if (len > best.len) best = { el: d, len: len }; }); content = best.el || document.body; } var clone = content.cloneNode(true); ['script','style','nav','header','footer','aside'].forEach(function(tag){ clone.querySelectorAll(tag).forEach(function(el){ el.remove(); }); }); var html = clone.innerHTML; var css = [ 'body{font-family:-apple-system,Georgia,serif;max-width:680px;margin:0 auto;', 'padding:20px 20px 60px;line-height:1.75;font-size:18px;background:#fafafa;color:#1c1c1e}', 'h1{font-size:1.6em;line-height:1.3;margin:0 0 .5em}', 'h2,h3,h4{line-height:1.35;margin:1.5em 0 .5em}p{margin:0 0 1em}', 'img{max-width:100%;height:auto;border-radius:8px;margin:8px 0}', 'a{color:#007aff;text-decoration:none}', 'blockquote{border-left:3px solid #ccc;margin:1em 0;padding:0 1em;color:#555}', 'code,pre{font-family:ui-monospace,monospace;font-size:.9em;background:#f0f0f0;border-radius:4px;padding:2px 4px}', 'pre code{padding:0;background:none}pre{padding:12px;overflow-x:auto}', '@media(prefers-color-scheme:dark){body{background:#1c1c1e;color:#e5e5e7}', 'blockquote{border-color:#555;color:#aaa}code,pre{background:#2c2c2e}a{color:#0a84ff}}' ].join(''); document.open(); document.write('' + '' + '' + '' + title + '' + '

' + title + '

' + html + ''); document.close(); return 'ok'; })() """ } } // MARK: - WebView representable private struct WebViewRepresentable: UIViewRepresentable { let webView: WKWebView func makeUIView(context: Context) -> WKWebView { webView } func updateUIView(_ uiView: WKWebView, context: Context) {} } // MARK: - BrowserView struct BrowserView: View { let initialURL: URL let initialTitle: String let claude: ClaudeService @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @State private var state: BrowserState @State private var readerMode = false @State private var showPodcast = false init(url: URL, title: String, claude: ClaudeService) { self.initialURL = url self.initialTitle = title self.claude = claude self._state = State(initialValue: BrowserState(url: url)) } var body: some View { NavigationStack { ZStack(alignment: .top) { WebViewRepresentable(webView: state.webView) .ignoresSafeArea(edges: .bottom) // Reading progress bar if state.readingProgress > 0.01 && state.readingProgress < 0.99 { GeometryReader { geo in Rectangle() .fill(Color.blue.opacity(0.7)) .frame(width: geo.size.width * state.readingProgress, height: 3) } .frame(height: 3) .animation(.linear(duration: 0.1), value: state.readingProgress) } if state.isLoading { ProgressView() .padding(.top, 8) } } .navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button { dismiss() } label: { Image(systemName: "xmark") .fontWeight(.semibold) } } ToolbarItem(placement: .topBarTrailing) { Button { openURL(state.currentURL ?? initialURL) } label: { Image(systemName: "safari") } } ToolbarItemGroup(placement: .bottomBar) { Button { state.webView.goBack() } label: { Image(systemName: "chevron.left") } .disabled(!state.canGoBack) Spacer() Button { state.webView.goForward() } label: { Image(systemName: "chevron.right") } .disabled(!state.canGoForward) Spacer() Button { if readerMode { state.exitReaderMode(); readerMode = false } else { state.enterReaderMode { readerMode = true } } } label: { Image(systemName: readerMode ? "doc.text.fill" : "doc.text") } .disabled(state.isLoading) Spacer() Button { showPodcast = true } label: { Image(systemName: "headphones") } .disabled(state.isLoading) Spacer() ShareLink(item: state.currentURL ?? initialURL) { Image(systemName: "square.and.arrow.up") } } } } .onDisappear { let urlStr = (state.currentURL ?? initialURL).absoluteString let p = state.readingProgress if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) } } .sheet(isPresented: $showPodcast) { PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude) } } }