import Foundation import UIKit import ComponentFlow import TelegramCore import Postbox import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import AccountContext import WebKit import AppBundle private final class IpfsSchemeHandler: NSObject, WKURLSchemeHandler { private final class PendingTask { let sourceTask: any WKURLSchemeTask var urlSessionTask: URLSessionTask? let isCompleted = Atomic(value: false) init(sourceTask: any WKURLSchemeTask) { self.sourceTask = sourceTask var cleanUrl = sourceTask.request.url!.absoluteString if let range = cleanUrl.range(of: "/ipfs/") { cleanUrl = "ipfs://" + String(cleanUrl[range.upperBound...]) } else if let range = cleanUrl.range(of: "/ipns/") { cleanUrl = "ipns://" + String(cleanUrl[range.upperBound...]) } print("Load: \(cleanUrl)") cleanUrl = cleanUrl.replacingOccurrences(of: "ipns://", with: "ipns/") cleanUrl = cleanUrl.replacingOccurrences(of: "ipfs://", with: "ipfs/") let mappedUrl = "https://cloudflare-ipfs.com/\(cleanUrl)" let isCompleted = self.isCompleted self.urlSessionTask = URLSession.shared.dataTask(with: URLRequest(url: URL(string: mappedUrl)!), completionHandler: { data, response, error in if isCompleted.swap(true) { return } if let error { sourceTask.didFailWithError(error) } else { if let response { sourceTask.didReceive(response) } if let data { sourceTask.didReceive(data) } sourceTask.didFinish() } }) self.urlSessionTask?.resume() } func cancel() { if let urlSessionTask = self.urlSessionTask { self.urlSessionTask = nil if !self.isCompleted.swap(true) { switch urlSessionTask.state { case .running, .suspended: urlSessionTask.cancel() default: break } } } } } private var pendingTasks: [PendingTask] = [] func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { self.pendingTasks.append(PendingTask(sourceTask: urlSchemeTask)) } func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { if let index = self.pendingTasks.firstIndex(where: { $0.sourceTask === urlSchemeTask }) { let task = self.pendingTasks[index] self.pendingTasks.remove(at: index) task.cancel() } } } final class BrowserWebContent: UIView, BrowserContent, UIScrollViewDelegate { private let webView: WKWebView private var _state: BrowserContentState private let statePromise: Promise var state: Signal { return self.statePromise.get() } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } init(context: AccountContext, url: String) { let configuration = WKWebViewConfiguration() if context.sharedContext.immediateExperimentalUISettings.browserExperiment { configuration.setURLSchemeHandler(IpfsSchemeHandler(), forURLScheme: "ipns") configuration.setURLSchemeHandler(IpfsSchemeHandler(), forURLScheme: "ipfs") } self.webView = WKWebView(frame: CGRect(), configuration: configuration) self.webView.allowsLinkPreview = false if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { self.webView.scrollView.contentInsetAdjustmentBehavior = .never } var title: String = "" if let parsedUrl = URL(string: url) { let request = URLRequest(url: parsedUrl) self.webView.load(request) title = parsedUrl.host ?? "" } self._state = BrowserContentState(title: title, url: url, estimatedProgress: 0.0, contentType: .webPage) self.statePromise = Promise(self._state) super.init(frame: .zero) self.webView.allowsBackForwardNavigationGestures = true self.webView.scrollView.delegate = self self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.title), options: [], context: nil) self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.url), options: [], context: nil) self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: [], context: nil) self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.canGoBack), options: [], context: nil) self.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.canGoForward), options: [], context: nil) self.addSubview(self.webView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.title)) self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.url)) self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress)) self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.canGoBack)) self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.canGoForward)) } func setFontSize(_ fontSize: CGFloat) { let js = "document.getElementsByTagName('body')[0].style.webkitTextSizeAdjust='\(Int(fontSize * 100.0))%'" self.webView.evaluateJavaScript(js, completionHandler: nil) } func setForceSerif(_ force: Bool) { let js: String if force { js = "document.getElementsByTagName(\'body\')[0].style.fontFamily = 'Georgia, serif';" } else { js = "document.getElementsByTagName(\'body\')[0].style.fontFamily = '\"Lucida Grande\", \"Lucida Sans Unicode\", Arial, Helvetica, Verdana, sans-serif';" } self.webView.evaluateJavaScript(js) { _, _ in } } private var didSetupSearch = false private func setupSearch(completion: @escaping () -> Void) { guard !self.didSetupSearch else { completion() return } let bundle = getAppBundle() guard let scriptPath = bundle.path(forResource: "UIWebViewSearch", ofType: "js") else { return } guard let scriptData = try? Data(contentsOf: URL(fileURLWithPath: scriptPath)) else { return } guard let script = String(data: scriptData, encoding: .utf8) else { return } self.didSetupSearch = true self.webView.evaluateJavaScript(script, completionHandler: { _, error in if error != nil { print() } completion() }) } private var previousQuery: String? func setSearch(_ query: String?, completion: ((Int) -> Void)?) { guard self.previousQuery != query else { return } self.previousQuery = query self.setupSearch { [weak self] in if let query = query { let js = "uiWebview_HighlightAllOccurencesOfString('\(query)')" self?.webView.evaluateJavaScript(js, completionHandler: { [weak self] _, _ in let js = "uiWebview_SearchResultCount" self?.webView.evaluateJavaScript(js, completionHandler: { [weak self] result, _ in if let result = result as? NSNumber { self?.searchResultsCount = result.intValue completion?(result.intValue) } else { completion?(0) } }) }) } else { let js = "uiWebview_RemoveAllHighlights()" self?.webView.evaluateJavaScript(js, completionHandler: nil) self?.currentSearchResult = 0 self?.searchResultsCount = 0 } } } private var currentSearchResult: Int = 0 private var searchResultsCount: Int = 0 func scrollToPreviousSearchResult(completion: ((Int, Int) -> Void)?) { let searchResultsCount = self.searchResultsCount var index = self.currentSearchResult - 1 if index < 0 { index = searchResultsCount - 1 } self.currentSearchResult = index let js = "uiWebview_ScrollTo('\(searchResultsCount - index - 1)')" self.webView.evaluateJavaScript(js, completionHandler: { _, _ in completion?(index, searchResultsCount) }) } func scrollToNextSearchResult(completion: ((Int, Int) -> Void)?) { let searchResultsCount = self.searchResultsCount var index = self.currentSearchResult + 1 if index >= searchResultsCount { index = 0 } self.currentSearchResult = index let js = "uiWebview_ScrollTo('\(searchResultsCount - index - 1)')" self.webView.evaluateJavaScript(js, completionHandler: { _, _ in completion?(index, searchResultsCount) }) } func navigateBack() { self.webView.goBack() } func navigateForward() { self.webView.goForward() } func scrollToTop() { self.webView.scrollView.setContentOffset(CGPoint(x: 0.0, y: -self.webView.scrollView.contentInset.top), animated: true) } func updateLayout(size: CGSize, insets: UIEdgeInsets, transition: ComponentTransition) { var scrollInsets = insets scrollInsets.top = 0.0 if self.webView.scrollView.contentInset != insets { self.webView.scrollView.contentInset = scrollInsets self.webView.scrollView.scrollIndicatorInsets = scrollInsets } self.previousScrollingOffset = ScrollingOffsetState(value: self.webView.scrollView.contentOffset.y, isDraggingOrDecelerating: self.webView.scrollView.isDragging || self.webView.scrollView.isDecelerating) transition.setFrame(view: self.webView, frame: CGRect(origin: CGPoint(x: 0.0, y: insets.top), size: CGSize(width: size.width, height: size.height - insets.top))) } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { let updateState: ((BrowserContentState) -> BrowserContentState) -> Void = { f in let updated = f(self._state) self._state = updated self.statePromise.set(.single(self._state)) } if keyPath == "title" { updateState { $0.withUpdatedTitle(self.webView.title ?? "") } } else if keyPath == "URL" { updateState { $0.withUpdatedUrl(self.webView.url?.absoluteString ?? "") } self.didSetupSearch = false } else if keyPath == "estimatedProgress" { updateState { $0.withUpdatedEstimatedProgress(self.webView.estimatedProgress) } } else if keyPath == "canGoBack" { updateState { $0.withUpdatedCanGoBack(self.webView.canGoBack) } self.webView.disablesInteractiveTransitionGestureRecognizer = self.webView.canGoBack } else if keyPath == "canGoForward" { updateState { $0.withUpdatedCanGoForward(self.webView.canGoForward) } } } private struct ScrollingOffsetState: Equatable { var value: CGFloat var isDraggingOrDecelerating: Bool } private var previousScrollingOffset: ScrollingOffsetState? func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrollingOffset(isReset: false, transition: .immediate) } private func snapScrollingOffsetToInsets() { let transition = ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)) self.updateScrollingOffset(isReset: false, transition: transition) } public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { self.snapScrollingOffsetToInsets() } } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { self.snapScrollingOffsetToInsets() } private func updateScrollingOffset(isReset: Bool, transition: ComponentTransition) { let scrollView = self.webView.scrollView let isInteracting = scrollView.isDragging || scrollView.isDecelerating if let previousScrollingOffsetValue = self.previousScrollingOffset { let currentBounds = scrollView.bounds let offsetToTopEdge = max(0.0, currentBounds.minY - 0.0) let offsetToBottomEdge = max(0.0, scrollView.contentSize.height - currentBounds.maxY) let relativeOffset = scrollView.contentOffset.y - previousScrollingOffsetValue.value self.onScrollingUpdate(ContentScrollingUpdate( relativeOffset: relativeOffset, absoluteOffsetToTopEdge: offsetToTopEdge, absoluteOffsetToBottomEdge: offsetToBottomEdge, isReset: isReset, isInteracting: isInteracting, transition: transition )) } self.previousScrollingOffset = ScrollingOffsetState(value: scrollView.contentOffset.y, isDraggingOrDecelerating: isInteracting) } }