import Foundation import UIKit import Display import ComponentFlow import TelegramCore import Postbox import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import PresentationDataUtils import AccountContext @preconcurrency import WebKit import AppBundle import PromptUI import SafariServices import ShareController import UndoUI import LottieComponent import MultilineTextComponent import UrlEscaping import UrlHandling import SaveProgressScreen import DeviceModel private final class TonSchemeHandler: NSObject, WKURLSchemeHandler { private final class PendingTask { let sourceTask: any WKURLSchemeTask var urlSessionTask: URLSessionTask? let isCompleted = Atomic(value: false) init(proxyServerHost: String, sourceTask: any WKURLSchemeTask) { self.sourceTask = sourceTask let requestUrl = sourceTask.request.url var mappedHost: String = "" if let host = sourceTask.request.url?.host { mappedHost = host mappedHost = mappedHost.replacingOccurrences(of: "-", with: "-h") mappedHost = mappedHost.replacingOccurrences(of: ".", with: "-d") } var mappedPath = "" if let path = sourceTask.request.url?.path, !path.isEmpty { mappedPath = path if !path.hasPrefix("/") { mappedPath = "/\(mappedPath)" } } let mappedUrl = "https://\(mappedHost).\(proxyServerHost)\(mappedPath)" 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 { if let response = response as? HTTPURLResponse, let requestUrl { if let updatedResponse = HTTPURLResponse( url: requestUrl, statusCode: response.statusCode, httpVersion: "HTTP/1.1", headerFields: response.allHeaderFields as? [String: String] ?? [:] ) { sourceTask.didReceive(updatedResponse) } else { sourceTask.didReceive(response) } } else { 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 let proxyServerHost: String private var pendingTasks: [PendingTask] = [] init(proxyServerHost: String) { self.proxyServerHost = proxyServerHost } func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { self.pendingTasks.append(PendingTask(proxyServerHost: self.proxyServerHost, 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 WebView: WKWebView { var customBottomInset: CGFloat = 0.0 { didSet { if self.customBottomInset != oldValue { self.setNeedsLayout() } } } override var safeAreaInsets: UIEdgeInsets { return UIEdgeInsets(top: 0.0, left: 0.0, bottom: self.customBottomInset, right: 0.0) } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { var result = super.point(inside: point, with: event) if !result && point.x > 0.0 && point.y < self.frame.width && point.y > 0.0 && point.y < self.frame.height + 83.0 { result = true } return result } } private class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { private let f: (WKScriptMessage) -> () init(_ f: @escaping (WKScriptMessage) -> ()) { self.f = f super.init() } func userContentController(_ controller: WKUserContentController, didReceive scriptMessage: WKScriptMessage) { self.f(scriptMessage) } } private func computedUserAgent() -> String { func getFirmwareVersion() -> String? { var size = 0 sysctlbyname("kern.osversion", nil, &size, nil, 0) var str = [CChar](repeating: 0, count: size) sysctlbyname("kern.osversion", &str, &size, nil, 0) return String(cString: str) } let osVersion = UIDevice.current.systemVersion let firmwareVersion = getFirmwareVersion() ?? "15E148" return DeviceModel.current.isIpad ? "Version/\(osVersion) Safari/605.1.15" : "Version/\(osVersion) Mobile/\(firmwareVersion) Safari/604.1" } final class BrowserWebContent: UIView, BrowserContent, WKNavigationDelegate, WKUIDelegate, UIScrollViewDelegate { private let context: AccountContext private var presentationData: PresentationData let webView: WebView var readability: Readability? private let errorView: ComponentHostView private var currentError: Error? let uuid: UUID private var _state: BrowserContentState private let statePromise: Promise var currentState: BrowserContentState { return self._state } var state: Signal { return self.statePromise.get() } private let faviconDisposable = MetaDisposable() var pushContent: (BrowserScreen.Subject, BrowserContent?) -> Void = { _, _ in } var openAppUrl: (String) -> Void = { _ in } var onScrollingUpdate: (ContentScrollingUpdate) -> Void = { _ in } var minimize: () -> Void = { } var close: () -> Void = { } var present: (ViewController, Any?) -> Void = { _, _ in } var presentInGlobalOverlay: (ViewController) -> Void = { _ in } var getNavigationController: () -> NavigationController? = { return nil } var cancelInteractiveTransitionGestures: () -> Void = {} private var tempFile: TempBoxFile? init(context: AccountContext, presentationData: PresentationData, url: String, preferredConfiguration: WKWebViewConfiguration? = nil) { self.context = context self.uuid = UUID() self.presentationData = presentationData var handleScriptMessageImpl: ((WKScriptMessage) -> Void)? let configuration: WKWebViewConfiguration if let preferredConfiguration { configuration = preferredConfiguration } else { configuration = WKWebViewConfiguration() var proxyServerHost = "magic.org" if let data = context.currentAppConfiguration.with({ $0 }).data, let hostValue = data["ton_proxy_address"] as? String { proxyServerHost = hostValue } configuration.setURLSchemeHandler(TonSchemeHandler(proxyServerHost: proxyServerHost), forURLScheme: "tonsite") configuration.allowsInlineMediaPlayback = true if #available(iOSApplicationExtension 10.0, iOS 10.0, *) { configuration.mediaTypesRequiringUserActionForPlayback = [] } else { configuration.mediaPlaybackRequiresUserAction = false } let contentController = WKUserContentController() let videoScript = WKUserScript(source: videoSource, injectionTime: .atDocumentStart, forMainFrameOnly: false) contentController.addUserScript(videoScript) let touchScript = WKUserScript(source: setupTouchObservers, injectionTime: .atDocumentStart, forMainFrameOnly: false) contentController.addUserScript(touchScript) let eventProxyScript = WKUserScript(source: eventProxySource, injectionTime: .atDocumentStart, forMainFrameOnly: false) contentController.addUserScript(eventProxyScript) contentController.add(WeakScriptMessageHandler { message in handleScriptMessageImpl?(message) }, name: "performAction") configuration.userContentController = contentController configuration.applicationNameForUserAgent = computedUserAgent() } self.webView = WebView(frame: CGRect(), configuration: configuration) self.webView.allowsLinkPreview = true if #available(iOS 11.0, *) { self.webView.scrollView.contentInsetAdjustmentBehavior = .never } var title: String = "" if url.hasPrefix("file://") { var updatedPath = url let tempFile = TempBox.shared.file(path: url.replacingOccurrences(of: "file://", with: ""), fileName: "file.xlsx") updatedPath = tempFile.path self.tempFile = tempFile let request = URLRequest(url: URL(fileURLWithPath: updatedPath)) self.webView.load(request) } else if let parsedUrl = URL(string: url) { let request = URLRequest(url: parsedUrl) self.webView.load(request) title = getDisplayUrl(url, hostOnly: true) } self.errorView = ComponentHostView() self._state = BrowserContentState(title: title, url: url, estimatedProgress: 0.1, readingProgress: 0.0, contentType: .webPage) self.statePromise = Promise(self._state) super.init(frame: .zero) self.backgroundColor = presentationData.theme.list.plainBackgroundColor self.webView.backgroundColor = presentationData.theme.list.plainBackgroundColor self.webView.alpha = 0.0 self.webView.allowsBackForwardNavigationGestures = true self.webView.scrollView.delegate = self self.webView.scrollView.clipsToBounds = false self.webView.navigationDelegate = self self.webView.uiDelegate = 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.webView.addObserver(self, forKeyPath: #keyPath(WKWebView.hasOnlySecureContent), options: [], context: nil) if #available(iOS 15.0, *) { self.webView.underPageBackgroundColor = presentationData.theme.list.plainBackgroundColor } if #available(iOS 16.4, *) { self.webView.isInspectable = true } self.addSubview(self.webView) self.webView.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in if let self, self.webView.canGoBack { return true } else { return false } } self.webView.interactiveTransitionGestureRecognizerTest = { [weak self] point in if let self { if let result = self.webView.hitTest(point, with: nil), let scrollView = findScrollView(view: result), scrollView.isDescendant(of: self.webView) { if scrollView.contentSize.width > scrollView.frame.width, scrollView.contentOffset.x > -scrollView.contentInset.left { return true } } } return false } handleScriptMessageImpl = { [weak self] message in self?.handleScriptMessage(message) } } 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)) self.webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.hasOnlySecureContent)) self.faviconDisposable.dispose() self.instantPageDisposable.dispose() } private func handleScriptMessage(_ message: WKScriptMessage) { guard let body = message.body as? [String: Any] else { return } guard let eventName = body["eventName"] as? String else { return } switch eventName { case "cancellingTouch": self.cancelInteractiveTransitionGestures() default: break } } func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData if #available(iOS 15.0, *) { self.backgroundColor = presentationData.theme.list.plainBackgroundColor self.webView.underPageBackgroundColor = presentationData.theme.list.plainBackgroundColor } if let (size, insets, fullInsets, safeInsets) = self.validLayout { self.updateLayout(size: size, insets: insets, fullInsets: fullInsets, safeInsets: safeInsets, transition: .immediate) } } var currentFontState = BrowserPresentationState.FontState(size: 100, isSerif: false) func updateFontState(_ state: BrowserPresentationState.FontState) { self.updateFontState(state, force: false) } func updateFontState(_ state: BrowserPresentationState.FontState, force: Bool) { self.currentFontState = state let fontFamily = state.isSerif ? "'Georgia, serif'" : "null" let textSizeAdjust = state.size != 100 ? "'\(state.size)%'" : "null" let js = "\(setupFontFunctions) setTelegramFontOverrides(\(fontFamily), \(textSizeAdjust))"; self.webView.evaluateJavaScript(js) { _, _ in } } func toggleInstantView(_ enabled: Bool) { if enabled { if let instantPage = self.instantPage { self.pushContent(.instantPage(webPage: instantPage, anchor: nil, sourceLocation: InstantPageSourceLocation(userLocation: .other, peerType: .channel), preloadedResources: self.instantPageResources), self) } else if let readability = self.readability { readability.webView.frame = self.webView.frame self.addSubview(readability.webView) var collapsedFrame = readability.webView.frame collapsedFrame.size.height = 0.0 readability.webView.clipsToBounds = true readability.webView.layer.animateFrame(from: collapsedFrame, to: readability.webView.frame, duration: 0.3) } } else if let readability = self.readability { var collapsedFrame = readability.webView.frame collapsedFrame.size.height = 0.0 readability.webView.layer.animateFrame(from: readability.webView.frame, to: collapsedFrame, duration: 0.3, removeOnCompletion: false, completion: { _ in readability.webView.removeFromSuperview() readability.webView.layer.removeAllAnimations() }) } } 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 findSession: Any? private var previousQuery: String? func setSearch(_ query: String?, completion: ((Int) -> Void)?) { guard self.previousQuery != query else { return } if #available(iOS 16.0, *), !"".isEmpty { if let query { var findSession: UIFindSession? if let current = self.findSession as? UIFindSession { findSession = current } else { self.webView.isFindInteractionEnabled = true if let findInteraction = self.webView.findInteraction, let webView = self.webView as? UIFindInteractionDelegate, let session = webView.findInteraction(findInteraction, sessionFor: self.webView) { // session.setValue(findInteraction, forKey: "_parentInteraction") // findInteraction.setValue(session, forKey: "_activeFindSession") findSession = session self.findSession = session webView.findInteraction?(findInteraction, didBegin: session) } } if let findSession { findSession.performSearch(query: query, options: BrowserSearchOptions()) self.webView.findInteraction?.updateResultCount() completion?(findSession.resultCount) } } else { if let findInteraction = self.webView.findInteraction, let webView = self.webView as? UIFindInteractionDelegate, let session = self.findSession as? UIFindSession { webView.findInteraction?(findInteraction, didEnd: session) self.findSession = nil self.webView.isFindInteractionEnabled = false } } } else { self.setupSearch { [weak self] in if let query, !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 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 completion?(0) } } } self.previousQuery = query } private var currentSearchResult: Int = 0 private var searchResultsCount: Int = 0 func scrollToPreviousSearchResult(completion: ((Int, Int) -> Void)?) { if #available(iOS 16.0, *), !"".isEmpty { if let session = self.findSession as? UIFindSession { session.highlightNextResult(in: .backward) completion?(session.highlightedResultIndex, session.resultCount) } } else { 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)?) { if #available(iOS 16.0, *), !"".isEmpty { if let session = self.findSession as? UIFindSession { session.highlightNextResult(in: .forward) completion?(session.highlightedResultIndex, session.resultCount) } } else { 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 stop() { self.webView.stopLoading() } func reload() { self.webView.reload() } func navigateBack() { self.webView.goBack() } func navigateForward() { self.webView.goForward() } func navigateTo(historyItem: BrowserContentState.HistoryItem) { if let webItem = historyItem.webItem { self.webView.go(to: webItem) } } func navigateTo(address: String) { let finalUrl = explicitUrl(address) guard let url = URL(string: finalUrl) else { return } self.webView.load(URLRequest(url: url)) } func scrollToTop() { self.webView.scrollView.setContentOffset(CGPoint(x: 0.0, y: -self.webView.scrollView.contentInset.top), animated: true) } private var validLayout: (CGSize, UIEdgeInsets, UIEdgeInsets, UIEdgeInsets)? func updateLayout(size: CGSize, insets: UIEdgeInsets, fullInsets: UIEdgeInsets, safeInsets: UIEdgeInsets, transition: ComponentTransition) { self.validLayout = (size, insets, fullInsets, safeInsets) self.previousScrollingOffset = ScrollingOffsetState(value: self.webView.scrollView.contentOffset.y, isDraggingOrDecelerating: self.webView.scrollView.isDragging || self.webView.scrollView.isDecelerating) let currentBounds = self.webView.scrollView.bounds let offsetToBottomEdge = max(0.0, self.webView.scrollView.contentSize.height - currentBounds.maxY) var bottomInset = insets.bottom if offsetToBottomEdge < 128.0 { bottomInset = fullInsets.bottom } let webViewFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: size.width - insets.left - insets.right, height: size.height - insets.top - bottomInset)) var refresh = false if self.webView.frame.width > 0 && webViewFrame.width != self.webView.frame.width { refresh = true } transition.setFrame(view: self.webView, frame: webViewFrame) if refresh { self.webView.reloadInputViews() } self.webView.customBottomInset = safeInsets.bottom * (1.0 - insets.bottom / fullInsets.bottom) // self.webView.scrollView.scrollIndicatorInsets = UIEdgeInsets(top: 0.0, left: -insets.left, bottom: 0.0, right: -insets.right) // self.webView.scrollView.horizontalScrollIndicatorInsets = UIEdgeInsets(top: 0.0, left: -insets.left, bottom: 0.0, right: -insets.right) if let error = self.currentError { let errorSize = self.errorView.update( transition: .immediate, component: AnyComponent( ErrorComponent( theme: self.presentationData.theme, title: self.presentationData.strings.Browser_ErrorTitle, text: error.localizedDescription, insets: insets ) ), environment: {}, containerSize: CGSize(width: size.width, height: size.height) ) if self.errorView.superview == nil { self.addSubview(self.errorView) self.errorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } self.errorView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - errorSize.width) / 2.0), y: insets.top + floorToScreenPixels((size.height - insets.top - insets.bottom - errorSize.height) / 2.0)), size: errorSize) } else if self.errorView.superview != nil { self.errorView.removeFromSuperview() } } private func updateState(_ f: (BrowserContentState) -> BrowserContentState) { let updated = f(self._state) self._state = updated self.statePromise.set(.single(self._state)) } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "title" { self.updateState { $0.withUpdatedTitle(self.webView.title ?? "") } } else if keyPath == "URL" { if let url = self.webView.url { self.updateState { $0.withUpdatedUrl(url.absoluteString) } } self.didSetupSearch = false } else if keyPath == "estimatedProgress" { if self.webView.estimatedProgress >= 0.1 && self.webView.alpha.isZero { self.webView.alpha = 1.0 self.webView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } self.updateState { $0.withUpdatedEstimatedProgress(self.webView.estimatedProgress) } } else if keyPath == "canGoBack" { self.updateState { $0.withUpdatedCanGoBack(self.webView.canGoBack) } } else if keyPath == "canGoForward" { self.updateState { $0.withUpdatedCanGoForward(self.webView.canGoForward) } } else if keyPath == "hasOnlySecureContent" { self.updateState { $0.withUpdatedIsSecure(self.webView.hasOnlySecureContent) } } } 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() if self.ignoreUpdatesUntilScrollingStopped { self.ignoreUpdatesUntilScrollingStopped = false } } } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { self.snapScrollingOffsetToInsets() if self.ignoreUpdatesUntilScrollingStopped { self.ignoreUpdatesUntilScrollingStopped = false } } private func updateScrollingOffset(isReset: Bool, transition: ComponentTransition) { guard !self.ignoreUpdatesUntilScrollingStopped else { return } 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) var readingProgress: CGFloat = 0.0 if !scrollView.contentSize.height.isZero { let value = (scrollView.contentOffset.y + scrollView.contentInset.top) / (scrollView.contentSize.height - scrollView.bounds.size.height + scrollView.contentInset.top) readingProgress = max(0.0, min(1.0, value)) } self.updateState { $0.withUpdatedReadingProgress(readingProgress) } } private var ignoreUpdatesUntilScrollingStopped = false func resetScrolling() { self.updateScrollingOffset(isReset: true, transition: .spring(duration: 0.4)) if self.webView.scrollView.isDecelerating { self.ignoreUpdatesUntilScrollingStopped = true } } @available(iOS 13.0, *) func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences, decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { // if #available(iOS 14.5, *), navigationAction.shouldPerformDownload { // self.presentDownloadConfirmation(fileName: navigationAction.request.mainDocumentURL?.lastPathComponent ?? "file", proceed: { download in // if download { // decisionHandler(.download, preferences) // } else { //// decisionHandler(.cancel, preferences) // } // }) // } else { if let url = navigationAction.request.url?.absoluteString { if (navigationAction.targetFrame == nil || navigationAction.targetFrame?.isMainFrame == true) && (isTelegramMeLink(url) || isTelegraPhLink(url) || url.hasPrefix("tg://")) && !url.contains("/auth/push?") && !self._state.url.contains("/auth/push?") { decisionHandler(.cancel, preferences) self.minimize() self.openAppUrl(url) } else { if let scheme = navigationAction.request.url?.scheme, !["http", "https", "tonsite", "about"].contains(scheme.lowercased()) { decisionHandler(.cancel, preferences) self.context.sharedContext.openExternalUrl(context: self.context, urlContext: .generic, url: url, forceExternal: true, presentationData: self.presentationData, navigationController: nil, dismissInput: {}) } else { decisionHandler(.allow, preferences) } } } else { decisionHandler(.allow, preferences) } // } } // func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { // if navigationResponse.canShowMIMEType { // decisionHandler(.allow) // } else if #available(iOS 14.5, *) { // self.presentDownloadConfirmation(fileName: navigationResponse.response.suggestedFilename ?? "file", proceed: { download in // if download { // decisionHandler(.download) // } else { // decisionHandler(.cancel) // } // }) // } else { // decisionHandler(.cancel) // } // } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if let url = navigationAction.request.url?.absoluteString { if (navigationAction.targetFrame == nil || navigationAction.targetFrame?.isMainFrame == true) && (isTelegramMeLink(url) || isTelegraPhLink(url) || url.hasPrefix("tg://")) { decisionHandler(.cancel) self.minimize() self.openAppUrl(url) } else { decisionHandler(.allow) } } else { decisionHandler(.allow) } } private let isLoaded = ValuePromise(false) private var instantPageDisposable = MetaDisposable() private var instantPage: TelegramMediaWebpage? private var instantPageResources: [Any]? func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { if let _ = self.currentError { self.currentError = nil if let (size, insets, fullInsets, safeInsets) = self.validLayout { self.updateLayout(size: size, insets: insets, fullInsets: fullInsets, safeInsets: safeInsets, transition: .immediate) } } self.updateFontState(self.currentFontState, force: true) self.readability = nil self.instantPage = nil self.instantPageResources = nil self.isLoaded.set(false) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { self.updateState {$0 .withUpdatedBackList(webView.backForwardList.backList.map { BrowserContentState.HistoryItem(webItem: $0) }) .withUpdatedForwardList(webView.backForwardList.forwardList.map { BrowserContentState.HistoryItem(webItem: $0) }) } self.parseFavicon() self.isLoaded.set(true) } func releaseInstantView() { self.instantPageDisposable.set(nil) } func requestInstantView() { guard self.readability == nil else { return } self.instantPageDisposable.set( (self.isLoaded.get() |> filter { $0 } |> take(1) |> deliverOnMainQueue).start(next: { [weak self] _ in guard let self else { return } guard let url = URL(string: self._state.url) else { return } if #available(iOS 14.5, *) { self.webView.createWebArchiveData { [weak self] result in guard let self, case let .success(data) = result else { return } let readability = Readability(url: url, archiveData: data, completionHandler: { [weak self] result, error in guard let self else { return } if let (webPage, resources) = result { self.updateState {$0 .withUpdatedHasInstantView(true) } self.instantPage = webPage self.instantPageResources = resources let _ = (updatedRemoteWebpage(postbox: self.context.account.postbox, network: self.context.account.network, accountPeerId: self.context.account.peerId, webPage: WebpageReference(TelegramMediaWebpage(webpageId: MediaId(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(url: self._state.url, displayUrl: "", hash: 0, type: nil, websiteName: nil, title: nil, text: nil, embedUrl: nil, embedType: nil, embedSize: nil, duration: nil, author: nil, isMediaLargeByDefault: nil, image: nil, file: nil, story: nil, attributes: [], instantPage: nil))))) |> deliverOnMainQueue).start(next: { [weak self] webPage in guard let self, let webPage, case let .Loaded(result) = webPage.content, let _ = result.instantPage else { return } self.instantPage = webPage }) } else { self.instantPage = nil self.instantPageResources = nil self.updateState {$0 .withUpdatedHasInstantView(false) } } }) self.readability = readability } } }) ) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { if [-1003, -1100, 102].contains((error as NSError).code) { if let url = (error as NSError).userInfo["NSErrorFailingURLKey"] as? URL, url.absoluteString.hasPrefix("itms-appss:") { } else { self.currentError = error } } else { self.currentError = nil } if let (size, insets, fullInsets, safeInsets) = self.validLayout { self.updateLayout(size: size, insets: insets, fullInsets: fullInsets, safeInsets: safeInsets, transition: .immediate) } } func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if navigationAction.targetFrame == nil { if let url = navigationAction.request.url?.absoluteString { if isTelegramMeLink(url) || isTelegraPhLink(url) || url.hasPrefix("tg://") { self.minimize() self.openAppUrl(url) } else { return self.open(url: url, configuration: configuration, new: true) } } } return nil } func webViewDidClose(_ webView: WKWebView) { Queue.mainQueue().after(0.5, { self.close() }) } @available(iOS 15.0, *) func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void) { decisionHandler(.prompt) } func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } var completed = false let alertController = textAlertController(context: self.context, updatedPresentationData: nil, title: nil, text: message, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: { if !completed { completed = true completionHandler() } })]) alertController.dismissed = { byOutsideTap in if byOutsideTap { if !completed { completed = true completionHandler() } } } self.present(alertController, nil) } func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } var completed = false let alertController = textAlertController(context: self.context, updatedPresentationData: nil, title: nil, text: message, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { if !completed { completed = true completionHandler(false) } }), TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: { if !completed { completed = true completionHandler(true) } })]) alertController.dismissed = { byOutsideTap in if byOutsideTap { if !completed { completed = true completionHandler(false) } } } self.present(alertController, nil) } func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) { var completed = false let promptController = promptController(sharedContext: self.context.sharedContext, updatedPresentationData: nil, text: prompt, value: defaultText, apply: { value in if !completed { completed = true if let value = value { completionHandler(value) } else { completionHandler(nil) } } }) promptController.dismissed = { byOutsideTap in if byOutsideTap { if !completed { completed = true completionHandler(nil) } } } self.present(promptController, nil) } @available(iOS 13.0, *) func webView(_ webView: WKWebView, contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) { guard let url = elementInfo.linkURL else { completionHandler(nil) return } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in return UIMenu(title: "", children: [ UIAction(title: presentationData.strings.Browser_ContextMenu_Open, image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Browser"), color: presentationData.theme.contextMenu.primaryColor), handler: { [weak self] _ in self?.open(url: url.absoluteString, new: false) }), UIAction(title: presentationData.strings.Browser_ContextMenu_OpenInNewTab, image: generateTintedImage(image: UIImage(bundleImageName: "Instant View/NewTab"), color: presentationData.theme.contextMenu.primaryColor), handler: { [weak self] _ in self?.open(url: url.absoluteString, new: true) }), UIAction(title: presentationData.strings.Browser_ContextMenu_AddToReadingList, image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReadingList"), color: presentationData.theme.contextMenu.primaryColor), handler: { _ in let _ = try? SSReadingList.default()?.addItem(with: url, title: nil, previewText: nil) }), UIAction(title: presentationData.strings.Browser_ContextMenu_CopyLink, image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: presentationData.theme.contextMenu.primaryColor), handler: { [weak self] _ in UIPasteboard.general.string = url.absoluteString self?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil) }), UIAction(title: presentationData.strings.Browser_ContextMenu_Share, image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: presentationData.theme.contextMenu.primaryColor), handler: { [weak self] _ in self?.share(url: url.absoluteString) }) ]) } completionHandler(configuration) } private func presentDownloadConfirmation(fileName: String, proceed: @escaping (Bool) -> Void) { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } var completed = false let alertController = textAlertController(context: self.context, updatedPresentationData: nil, title: nil, text: presentationData.strings.WebBrowser_Download_Confirmation(fileName).string, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { if !completed { completed = true proceed(false) } }), TextAlertAction(type: .defaultAction, title: presentationData.strings.WebBrowser_Download_Download, action: { if !completed { completed = true proceed(true) } })]) alertController.dismissed = { byOutsideTap in if byOutsideTap { if !completed { completed = true proceed(false) } } } self.present(alertController, nil) } @discardableResult private func open(url: String, configuration: WKWebViewConfiguration? = nil, new: Bool) -> WKWebView? { let subject: BrowserScreen.Subject = .webPage(url: url) if new, let navigationController = self.getNavigationController() { navigationController._keepModalDismissProgress = true self.minimize() let controller = BrowserScreen(context: self.context, subject: subject, preferredConfiguration: configuration, openPreviousOnClose: true) navigationController._keepModalDismissProgress = true navigationController.pushViewController(controller) return (controller.node.content.last as? BrowserWebContent)?.webView } else { self.pushContent(subject, nil) } return nil } private func share(url: String) { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let shareController = ShareController(context: self.context, subject: .url(url)) shareController.actionCompleted = { [weak self] in self?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil) } self.present(shareController, nil) } private func parseFavicon() { let addToRecentsWhenReady = self.addToRecentsWhenReady self.addToRecentsWhenReady = false struct Favicon: Equatable, Hashable { let url: String let dimensions: PixelDimensions? func hash(into hasher: inout Hasher) { hasher.combine(self.url) if let dimensions = self.dimensions { hasher.combine(dimensions.width) hasher.combine(dimensions.height) } } } let js = """ var favicons = []; var nodeList = document.getElementsByTagName('link'); for (var i = 0; i < nodeList.length; i++) { if((nodeList[i].getAttribute('rel') == 'icon')||(nodeList[i].getAttribute('rel') == 'shortcut icon')||(nodeList[i].getAttribute('rel').startsWith('apple-touch-icon'))) { const node = nodeList[i]; favicons.push({ url: node.getAttribute('href'), sizes: node.getAttribute('sizes') }); } } favicons; """ self.webView.evaluateJavaScript(js, completionHandler: { [weak self] jsResult, _ in guard let self, let favicons = jsResult as? [Any] else { return } var result = Set(); for favicon in favicons { if let faviconDict = favicon as? [String: Any], let urlString = faviconDict["url"] as? String { if let url = URL(string: urlString, relativeTo: self.webView.url) { let sizesString = faviconDict["sizes"] as? String; let sizeStrings = sizesString?.components(separatedBy: "x") ?? [] if (sizeStrings.count == 2) { let width = Int(sizeStrings[0]) let height = Int(sizeStrings[1]) let dimensions: PixelDimensions? if let width, let height { dimensions = PixelDimensions(width: Int32(width), height: Int32(height)) } else { dimensions = nil } result.insert(Favicon(url: url.absoluteString, dimensions: dimensions)) } else { result.insert(Favicon(url: url.absoluteString, dimensions: nil)) } } } } if result.isEmpty, let webViewUrl = self.webView.url { let schemeAndHostUrl = webViewUrl.deletingPathExtension() let url = schemeAndHostUrl.appendingPathComponent("favicon.ico") result.insert(Favicon(url: url.absoluteString, dimensions: nil)) } var largestIcon: Favicon? // = result.first(where: { $0.url.lowercased().contains(".svg") }) if largestIcon == nil { largestIcon = result.first for icon in result { let maxSize = largestIcon?.dimensions?.width ?? 0 if let width = icon.dimensions?.width, width > maxSize { largestIcon = icon } } } if let favicon = largestIcon { self.faviconDisposable.set((fetchFavicon(context: self.context, url: favicon.url, size: CGSize(width: 20.0, height: 20.0)) |> deliverOnMainQueue).startStrict(next: { [weak self] favicon in guard let self else { return } self.updateState { $0.withUpdatedFavicon(favicon) } if addToRecentsWhenReady { var image: TelegramMediaImage? if let favicon, let imageData = favicon.pngData() { let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) self.context.account.postbox.mediaBox.storeResourceData(resource.id, data: imageData) image = TelegramMediaImage( imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: Int64.random(in: Int64.min ... Int64.max)), representations: [ TelegramMediaImageRepresentation( dimensions: PixelDimensions(width: Int32(favicon.size.width), height: Int32(favicon.size.height)), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false ) ], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: [] ) } let webPage = TelegramMediaWebpage(webpageId: MediaId(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent( url: self._state.url, displayUrl: self._state.url, hash: 0, type: "", websiteName: self._state.title, title: self._state.title, text: nil, embedUrl: nil, embedType: nil, embedSize: nil, duration: nil, author: nil, isMediaLargeByDefault: nil, image: image, file: nil, story: nil, attributes: [], instantPage: nil)) ) let _ = addRecentlyVisitedLink(engine: self.context.engine, webPage: webPage).startStandalone() } })) } }) } private var addToRecentsWhenReady = false func addToRecentlyVisited() { self.addToRecentsWhenReady = true } func makeContentSnapshotView() -> UIView? { let configuration = WKSnapshotConfiguration() configuration.rect = CGRect(origin: .zero, size: self.webView.frame.size) let imageView = UIImageView() imageView.frame = CGRect(origin: .zero, size: self.webView.frame.size) self.webView.takeSnapshot(with: configuration, completionHandler: { image, _ in imageView.image = image }) return imageView } } private final class ErrorComponent: CombinedComponent { let theme: PresentationTheme let title: String let text: String let insets: UIEdgeInsets init( theme: PresentationTheme, title: String, text: String, insets: UIEdgeInsets ) { self.theme = theme self.title = title self.text = text self.insets = insets } static func ==(lhs: ErrorComponent, rhs: ErrorComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.title != rhs.title { return false } if lhs.text != rhs.text { return false } if lhs.insets != rhs.insets { return false } return true } static var body: Body { let background = Child(Rectangle.self) let animation = Child(LottieComponent.self) let title = Child(MultilineTextComponent.self) let text = Child(MultilineTextComponent.self) return { context in var contentHeight: CGFloat = 0.0 let animationSize = 148.0 let animationSpacing: CGFloat = 8.0 let textSpacing: CGFloat = 8.0 let constrainedWidth = context.availableSize.width - 76.0 - context.component.insets.left - context.component.insets.right let background = background.update( component: Rectangle(color: context.component.theme.list.plainBackgroundColor), availableSize: context.availableSize, transition: .immediate ) context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) ) let animation = animation.update( component: LottieComponent( content: LottieComponent.AppBundleContent(name: "ChatListNoResults") ), environment: {}, availableSize: CGSize(width: animationSize, height: animationSize), transition: .immediate ) contentHeight += animation.size.height + animationSpacing let title = title.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: context.component.title, font: Font.semibold(17.0), textColor: context.component.theme.list.itemSecondaryTextColor )), horizontalAlignment: .center ), environment: {}, availableSize: CGSize(width: constrainedWidth, height: context.availableSize.height), transition: .immediate ) contentHeight += title.size.height + textSpacing let text = text.update( component: MultilineTextComponent( text: .plain(NSAttributedString( string: context.component.text, font: Font.regular(15.0), textColor: context.component.theme.list.itemSecondaryTextColor )), horizontalAlignment: .center, maximumNumberOfLines: 0 ), environment: {}, availableSize: CGSize(width: constrainedWidth, height: context.availableSize.height), transition: .immediate ) contentHeight += text.size.height var originY = floor((context.availableSize.height - contentHeight) / 2.0) context.add(animation .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + animation.size.height / 2.0)) ) originY += animation.size.height + animationSpacing context.add(title .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + title.size.height / 2.0)) ) originY += title.size.height + textSpacing context.add(text .position(CGPoint(x: context.availableSize.width / 2.0, y: originY + text.size.height / 2.0)) ) return context.availableSize } } } let setupFontFunctions = """ (function() { const styleId = 'telegram-font-overrides'; function setTelegramFontOverrides(font, textSizeAdjust) { let style = document.getElementById(styleId); if (!style) { style = document.createElement('style'); style.id = styleId; document.head.appendChild(style); } let cssRules = '* {'; if (font !== null) { cssRules += ` font-family: ${font} !important; `; } if (textSizeAdjust !== null) { cssRules += ` -webkit-text-size-adjust: ${textSizeAdjust} !important; `; } cssRules += '}'; style.innerHTML = cssRules; if (font === null && textSizeAdjust === null) { style.parentNode.removeChild(style); } } window.setTelegramFontOverrides = setTelegramFontOverrides; })(); """ private let videoSource = """ function tgBrowserDisableWebkitEnterFullscreen(videoElement) { if (videoElement && videoElement.webkitEnterFullscreen) { Object.defineProperty(videoElement, 'webkitEnterFullscreen', { value: undefined }); } } function tgBrowserDisableFullscreenOnExistingVideos() { document.querySelectorAll('video').forEach(tgBrowserDisableWebkitEnterFullscreen); } function tgBrowserHandleMutations(mutations) { mutations.forEach((mutation) => { if (mutation.addedNodes && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach((newNode) => { if (newNode.tagName === 'VIDEO') { disableWebkitEnterFullscreen(newNode); } if (newNode.querySelectorAll) { newNode.querySelectorAll('video').forEach(disableWebkitEnterFullscreen); } }); } }); } tgBrowserDisableFullscreenOnExistingVideos(); const _tgbrowser_observer = new MutationObserver(tgBrowserHandleMutations); _tgbrowser_observer.observe(document.body, { childList: true, subtree: true }); function tgBrowserDisconnectObserver() { _tgbrowser_observer.disconnect(); } """ let setupTouchObservers = """ (function() { function saveOriginalCssProperties(element) { while (element) { const computedStyle = window.getComputedStyle(element); const propertiesToSave = ['transform', 'top', 'left']; element._originalProperties = {}; for (const property of propertiesToSave) { element._originalProperties[property] = computedStyle.getPropertyValue(property); } element = element.parentElement; } } function checkForCssChanges(element) { while (element) { if (!element._originalProperties) return false; const computedStyle = window.getComputedStyle(element); const modifiedProperties = ['transform', 'top', 'left']; for (const property of modifiedProperties) { if (computedStyle.getPropertyValue(property) !== element._originalProperties[property]) { return true; } } element = element.parentElement; } return false; } function clearOriginalCssProperties(element) { while (element) { delete element._originalProperties; element = element.parentElement; } } let touchedElement = null; document.addEventListener('touchstart', function(event) { touchedElement = event.target; saveOriginalCssProperties(touchedElement); }, { passive: true }); document.addEventListener('touchmove', function(event) { if (checkForCssChanges(touchedElement)) { TelegramWebviewProxy.postEvent("cancellingTouch", {}) console.log('CSS properties changed during touchmove'); } }, { passive: true }); document.addEventListener('touchend', function() { clearOriginalCssProperties(touchedElement); touchedElement = null; }, { passive: true }); })(); """ private let eventProxySource = "var TelegramWebviewProxyProto = function() {}; " + "TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { " + "window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); " + "}; " + "var TelegramWebviewProxy = new TelegramWebviewProxyProto();" @available(iOS 16.0, *) final class BrowserSearchOptions: UITextSearchOptions { override var wordMatchMethod: UITextSearchOptions.WordMatchMethod { return .contains } override var stringCompareOptions: NSString.CompareOptions { return .caseInsensitive } } private func findScrollView(view: UIView?) -> UIScrollView? { if let view = view { if let view = view as? UIScrollView { return view } return findScrollView(view: view.superview) } else { return nil } }