Files
Ilya Laktyushin 9b8abdb5ee Fix
2026-04-30 16:51:22 +02:00

391 lines
14 KiB
Swift

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<UITouch>, 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)"
}
}