import Foundation import UIKit import Display import WebKit import SwiftSignalKit import TelegramCore private let findActiveElementY = """ function getOffset(el) { const rect = el.getBoundingClientRect(); return { left: rect.left + window.scrollX, top: rect.top + window.scrollY }; } getOffset(document.activeElement).top; """ private class WeakGameScriptMessageHandler: 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 class WebViewTouchGestureRecognizer: UITapGestureRecognizer { override func touchesBegan(_ touches: Set, with event: UIEvent) { self.state = .began } } private func jsStringLiteral(_ value: String) -> String { if let data = try? JSONSerialization.data(withJSONObject: [value], options: []), let string = String(data: data, encoding: .utf8), string.hasPrefix("["), string.hasSuffix("]") { return String(string.dropFirst().dropLast()) } return "\"\"" } private func eventProxySource() -> String { return """ (function() { var TelegramWebviewProxyProto = function() {}; TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); }; window.TelegramWebviewProxy = new TelegramWebviewProxyProto(); })(); """ } private func securedEventProxySource(trustedOrigin: String) -> String { return """ (function() { if (window.location.origin !== \(jsStringLiteral(trustedOrigin))) { return; } var TelegramWebviewProxyProto = function() {}; TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); }; window.TelegramWebviewProxy = new TelegramWebviewProxyProto(); })(); """ } private let selectionSource = "var css = '*{-webkit-touch-callout:none;} :not(input):not(textarea):not([\"contenteditable\"=\"true\"]){-webkit-user-select:none;}';" + " var head = document.head || document.getElementsByTagName('head')[0];" + " var style = document.createElement('style'); style.type = 'text/css';" + " style.appendChild(document.createTextNode(css)); head.appendChild(style);" private let videoSource = """ document.addEventListener('DOMContentLoaded', () => { function tgBrowserDisableWebkitEnterFullscreen(videoElement) { if (videoElement && videoElement.webkitEnterFullscreen) { videoElement.setAttribute('playsinline', ''); } } 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') { tgBrowserDisableWebkitEnterFullscreen(newNode); } if (newNode.querySelectorAll) { newNode.querySelectorAll('video').forEach(tgBrowserDisableWebkitEnterFullscreen); } }); } }); } tgBrowserDisableFullscreenOnExistingVideos(); const _tgbrowser_observer = new MutationObserver(tgBrowserHandleMutations); _tgbrowser_observer.observe(document.body, { childList: true, subtree: true }); function tgBrowserDisconnectObserver() { _tgbrowser_observer.disconnect(); } }); """ final class WebAppWebView: WKWebView { var handleScriptMessage: (WKScriptMessage) -> Void = { _ in } private(set) var trustedOrigin: String? var customInsets: UIEdgeInsets = .zero { didSet { if self.customInsets != oldValue { self.setNeedsLayout() } } } override var safeAreaInsets: UIEdgeInsets { return UIEdgeInsets(top: self.customInsets.top, left: self.customInsets.left, bottom: self.customInsets.bottom, right: self.customInsets.right) } init(account: Account) { let configuration = WKWebViewConfiguration() if #available(iOS 17.0, *) { var uuid: UUID? if let current = UserDefaults.standard.object(forKey: "TelegramWebStoreUUID_\(account.id.int64)") as? String { uuid = UUID(uuidString: current)! } else { let mainAccountId: Int64 if let current = UserDefaults.standard.object(forKey: "TelegramWebStoreMainAccountId") as? Int64 { mainAccountId = current } else { mainAccountId = account.id.int64 UserDefaults.standard.set(mainAccountId, forKey: "TelegramWebStoreMainAccountId") } if account.id.int64 != mainAccountId { uuid = UUID() UserDefaults.standard.set(uuid!.uuidString, forKey: "TelegramWebStoreUUID_\(account.id.int64)") } } if let uuid { configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: uuid) } } let contentController = WKUserContentController() var handleScriptMessageImpl: ((WKScriptMessage) -> Void)? contentController.add(WeakGameScriptMessageHandler { message in handleScriptMessageImpl?(message) }, name: "performAction") let selectionScript = WKUserScript(source: selectionSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true) contentController.addUserScript(selectionScript) let videoScript = WKUserScript(source: videoSource, injectionTime: .atDocumentStart, forMainFrameOnly: false) contentController.addUserScript(videoScript) configuration.userContentController = contentController configuration.allowsInlineMediaPlayback = true configuration.allowsPictureInPictureMediaPlayback = false if #available(iOS 10.0, *) { configuration.mediaTypesRequiringUserActionForPlayback = [] } else { configuration.mediaPlaybackRequiresUserAction = false } super.init(frame: CGRect(), configuration: configuration) self.disablesInteractiveKeyboardGestureRecognizer = true self.isOpaque = false self.backgroundColor = .clear self.allowsLinkPreview = false self.scrollView.contentInsetAdjustmentBehavior = .never self.interactiveTransitionGestureRecognizerTest = { point -> Bool in return point.x > 30.0 } self.allowsBackForwardNavigationGestures = false if #available(iOS 16.4, *) { self.isInspectable = true } handleScriptMessageImpl = { [weak self] message in if let strongSelf = self { strongSelf.handleScriptMessage(message) } } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { print() } var useSecuredEventProxy = true func bindTrustedOrigin(from url: URL) { guard self.trustedOrigin == nil else { return } guard let origin = normalizedOrigin(url: url) else { return } self.trustedOrigin = origin let eventProxyScript = WKUserScript(source: securedEventProxySource(trustedOrigin: origin), injectionTime: .atDocumentStart, forMainFrameOnly: true) self.configuration.userContentController.addUserScript(eventProxyScript) } func setupEventProxySource() { self.useSecuredEventProxy = false let eventProxyScript = WKUserScript(source: eventProxySource(), injectionTime: .atDocumentStart, forMainFrameOnly: true) self.configuration.userContentController.addUserScript(eventProxyScript) } func isTrustedMainFrameMessage(_ message: WKScriptMessage) -> Bool { guard message.frameInfo.isMainFrame else { return false } if !self.useSecuredEventProxy { return true } guard let trustedOrigin = self.trustedOrigin else { return false } guard message.frameInfo.securityOriginString == trustedOrigin else { return false } if let currentOrigin = self.origin, currentOrigin != trustedOrigin { return false } return true } override func didMoveToSuperview() { super.didMoveToSuperview() if #available(iOS 11.0, *) { let webScrollView = self.subviews.compactMap { $0 as? UIScrollView }.first Queue.mainQueue().after(0.1, { let contentView = webScrollView?.subviews.first(where: { $0.interactions.count > 1 }) guard let dragInteraction = (contentView?.interactions.compactMap { $0 as? UIDragInteraction }.first) else { return } contentView?.removeInteraction(dragInteraction) }) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) } } func hideScrollIndicators() { var hiddenViews: [UIView] = [] for view in self.scrollView.subviews.reversed() { let minSize = min(view.frame.width, view.frame.height) if minSize < 4.0 { view.isHidden = true hiddenViews.append(view) } } Queue.mainQueue().after(2.0) { for view in hiddenViews { view.isHidden = false } } } func sendEvent(name: String, data: String?) { if self.useSecuredEventProxy { guard let trustedOrigin = self.trustedOrigin, self.origin == trustedOrigin else { return } } let script = "window.TelegramGameProxy && window.TelegramGameProxy.receiveEvent && window.TelegramGameProxy.receiveEvent(\"\(name)\", \(data ?? "null"))" self.evaluateJavaScript(script, completionHandler: { _, _ in }) } func updateMetrics(height: CGFloat, isExpanded: Bool, isStable: Bool, transition: ContainedViewLayoutTransition) { let viewportData = "{height:\(height), is_expanded:\(isExpanded ? "true" : "false"), is_state_stable:\(isStable ? "true" : "false")}" self.sendEvent(name: "viewport_changed", data: viewportData) let safeInsetsData = "{top:\(self.customInsets.top), bottom:\(self.customInsets.bottom), left:\(self.customInsets.left), right:\(self.customInsets.right)}" self.sendEvent(name: "safe_area_changed", data: safeInsetsData) } var lastTouchTimestamp: Double? private(set) var didTouchOnce = false var onFirstTouch: () -> Void = {} func scrollToActiveElement(layout: ContainerViewLayout, completion: @escaping (CGPoint) -> Void, transition: ContainedViewLayoutTransition) { self.evaluateJavaScript(findActiveElementY, completionHandler: { result, _ in if let result = result as? CGFloat { Queue.mainQueue().async { let convertedY = result - self.scrollView.contentOffset.y let viewportHeight = self.frame.height if convertedY < 0.0 || (convertedY + 44.0) > viewportHeight { let targetOffset: CGFloat if convertedY < 0.0 { targetOffset = max(0.0, result - 36.0) } else { targetOffset = max(0.0, result + 60.0 - viewportHeight) } let contentOffset = CGPoint(x: 0.0, y: targetOffset) completion(contentOffset) transition.animateView({ self.scrollView.contentOffset = contentOffset }) } } } }) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) self.lastTouchTimestamp = CACurrentMediaTime() if result != nil && !self.didTouchOnce { self.didTouchOnce = true self.onFirstTouch() } return result } override var inputAccessoryView: UIView? { return nil } var origin: String? { guard let url = self.url else { return nil } return normalizedOrigin(url: url) } } extension WKFrameInfo { var securityOriginString: String { let securityOrigin = self.securityOrigin return normalizedOrigin(scheme: securityOrigin.protocol, host: securityOrigin.host, port: securityOrigin.port == 0 ? nil : securityOrigin.port) ?? "" } } private func normalizedOrigin(url: URL) -> String? { return normalizedOrigin(scheme: url.scheme, host: url.host, port: url.port) } private func normalizedOrigin(scheme: String?, host: String?, port: Int?) -> String? { guard let scheme = scheme?.lowercased(), !scheme.isEmpty, let host = host?.lowercased(), !host.isEmpty else { return nil } let includePort: Bool if let port { includePort = !(scheme == "http" && port == 80) && !(scheme == "https" && port == 443) } else { includePort = false } if includePort, let port { return "\(scheme)://\(host):\(port)" } else { return "\(scheme)://\(host)" } }