import UIKit import WebKit final class DreamioWebViewController: UIViewController { private enum Constants { static let stremioWebURL = URL(string: "https://web.stremio.com/")! static let diagnosticsMessageHandler = "dreamioDiagnostics" static let streamCandidateMessageHandler = "dreamioStreamCandidate" } private lazy var webView: WKWebView = { let configuration = WKWebViewConfiguration() configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.preferences.javaScriptCanOpenWindowsAutomatically = true configuration.userContentController.add( WeakScriptMessageHandler(delegate: self), name: Constants.streamCandidateMessageHandler ) configuration.userContentController.addUserScript(Self.streamCandidateScript) #if DEBUG configuration.userContentController.add( WeakScriptMessageHandler(delegate: self), name: Constants.diagnosticsMessageHandler ) configuration.userContentController.addUserScript(Self.playbackDiagnosticsScript) #endif let webView = WKWebView(frame: .zero, configuration: configuration) webView.translatesAutoresizingMaskIntoConstraints = false webView.allowsBackForwardNavigationGestures = true webView.customUserAgent = "Dreamio/0.1 WKWebView" webView.navigationDelegate = self webView.uiDelegate = self webView.scrollView.contentInsetAdjustmentBehavior = .never #if DEBUG if #available(iOS 16.4, *) { webView.isInspectable = true } #endif return webView }() private let progressView: UIProgressView = { let view = UIProgressView(progressViewStyle: .bar) view.translatesAutoresizingMaskIntoConstraints = false view.tintColor = UIColor(red: 0.55, green: 0.35, blue: 0.95, alpha: 1.0) return view }() private var progressObservation: NSKeyValueObservation? private var userAgent: String? private var lastNativePlaybackURL: URL? private static let streamCandidateScript = WKUserScript( source: """ (() => { if (window.__dreamioStreamBridgeInstalled) { return; } window.__dreamioStreamBridgeInstalled = true; const nativePatterns = [ /\/\/addon\.debridio\.com\/play\//i, /\/\/torrentio\.strem\.fun\/resolve\//i, /\/\/download\.real-debrid\.com\//i, /\.(mkv|avi|webm)(?:[?#]|$)/i ]; const compatiblePatterns = [ /\.m3u8(?:[?#]|$)/i, /\.mp4(?:[?#]|$)/i ]; const looksNative = (url) => { if (!url || typeof url !== "string") { return false; } const directMatch = nativePatterns.some((pattern) => pattern.test(url)); const compatibleMatch = compatiblePatterns.some((pattern) => pattern.test(url)); return directMatch || (!compatibleMatch && /\.(mkv|avi|webm)(?:[?#]|$)/i.test(url)); }; const absoluteURL = (url) => { try { return new URL(url, window.location.href).href; } catch (_) { return ""; } }; const findResolverURL = () => { const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]")); const match = links .map((node) => node.getAttribute("href") || node.getAttribute("data-href") || node.getAttribute("data-url")) .map(absoluteURL) .find((url) => nativePatterns.some((pattern) => pattern.test(url))); return match || ""; }; const postCandidate = (rawURL, element) => { const url = absoluteURL(rawURL); if (!looksNative(url)) { return; } try { window.webkit.messageHandlers.dreamioStreamCandidate.postMessage({ url, resolverUrl: findResolverURL(), pageUrl: window.location.href, tagName: element && element.tagName ? element.tagName : "", currentSrc: element && element.currentSrc ? element.currentSrc : "" }); } catch (_) {} }; const inspectMedia = (node) => { if (!node) { return; } if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) { postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node); } if (node.querySelectorAll) { node.querySelectorAll("video, source").forEach(inspectMedia); } }; const srcDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "src"); if (srcDescriptor && srcDescriptor.set) { Object.defineProperty(HTMLMediaElement.prototype, "src", { get: srcDescriptor.get, set(value) { postCandidate(value, this); return srcDescriptor.set.call(this, value); } }); } const sourceSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLSourceElement.prototype, "src"); if (sourceSrcDescriptor && sourceSrcDescriptor.set) { Object.defineProperty(HTMLSourceElement.prototype, "src", { get: sourceSrcDescriptor.get, set(value) { postCandidate(value, this); return sourceSrcDescriptor.set.call(this, value); } }); } const originalSetAttribute = Element.prototype.setAttribute; Element.prototype.setAttribute = function(name, value) { if (String(name).toLowerCase() === "src" && (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement)) { postCandidate(value, this); } return originalSetAttribute.call(this, name, value); }; const originalLoad = HTMLMediaElement.prototype.load; HTMLMediaElement.prototype.load = function() { inspectMedia(this); this.querySelectorAll("source").forEach(inspectMedia); return originalLoad.call(this); }; document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true); document.addEventListener("error", (event) => inspectMedia(event.target), true); new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === "attributes" && mutation.attributeName === "src") { inspectMedia(mutation.target); } mutation.addedNodes.forEach(inspectMedia); }); }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["src"] }); inspectMedia(document); })(); """, injectionTime: .atDocumentStart, forMainFrameOnly: false ) #if DEBUG private static let playbackDiagnosticsScript = WKUserScript( source: """ (() => { if (window.__dreamioPlaybackDiagnosticsInstalled) { return; } window.__dreamioPlaybackDiagnosticsInstalled = true; const post = (type, payload = {}) => { try { window.webkit.messageHandlers.dreamioDiagnostics.postMessage({ type, payload, href: window.location.href }); } catch (_) {} }; const describeValue = (value) => { if (value instanceof Error) { return { name: value.name, message: value.message, stack: value.stack }; } if (typeof value === "string") { return value; } try { return JSON.stringify(value); } catch (_) { return String(value); } }; ["error", "warn"].forEach((level) => { const original = console[level]; console[level] = (...args) => { post(`console.${level}`, { args: args.map(describeValue) }); original.apply(console, args); }; }); window.addEventListener("unhandledrejection", (event) => { post("unhandledrejection", { reason: describeValue(event.reason) }); }); const videoState = (video) => ({ currentSrc: video.currentSrc || video.src || "", networkState: video.networkState, readyState: video.readyState, errorCode: video.error ? video.error.code : null, errorMessage: video.error ? video.error.message : null }); const attachVideoDiagnostics = (video) => { if (!video || video.__dreamioDiagnosticsAttached) { return; } video.__dreamioDiagnosticsAttached = true; video.addEventListener("error", () => { post("video.error", videoState(video)); }, true); }; document.querySelectorAll("video").forEach(attachVideoDiagnostics); new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node instanceof HTMLVideoElement) { attachVideoDiagnostics(node); } if (node.querySelectorAll) { node.querySelectorAll("video").forEach(attachVideoDiagnostics); } }); }); }).observe(document.documentElement, { childList: true, subtree: true }); })(); """, injectionTime: .atDocumentStart, forMainFrameOnly: false ) #endif override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground view.addSubview(webView) view.addSubview(progressView) NSLayoutConstraint.activate([ webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.topAnchor.constraint(equalTo: view.topAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) ]) progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in self?.updateProgress(webView.estimatedProgress) } loadDreamio() webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in self?.userAgent = result as? String } } private func loadDreamio() { let request = URLRequest(url: Constants.stremioWebURL) webView.load(request) } private func updateProgress(_ progress: Double) { progressView.isHidden = progress >= 1.0 progressView.setProgress(Float(progress), animated: true) } private func showLoadFailure(_ error: Error) { let alert = UIAlertController( title: "Could not load Dreamio", message: error.localizedDescription, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in self?.loadDreamio() }) present(alert, animated: true) } private func handleStreamCandidate(_ candidate: StreamCandidate) { guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else { return } if lastNativePlaybackURL == request.playbackURL { return } lastNativePlaybackURL = request.playbackURL #if DEBUG let classification = request.classification print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")") #endif let player = NativePlayerViewController(request: request) player.onDismiss = { [weak self] in self?.lastNativePlaybackURL = nil } present(player, animated: true) } #if DEBUG private func logDiagnostic(type: String, payload: Any, pageURL: String?) { let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown" let redactedPayload = redactDiagnosticValue(payload) print("[DreamioDiagnostics] \(type) page=\(redactedPageURL) payload=\(redactedPayload)") } private func redactDiagnosticValue(_ value: Any) -> Any { switch value { case let string as String: return redactedURLString(string) case let array as [Any]: return array.map(redactDiagnosticValue) case let dictionary as [String: Any]: return dictionary.reduce(into: [String: Any]()) { result, entry in result[entry.key] = redactDiagnosticValue(entry.value) } default: return value } } private func redactedURLString(_ value: String) -> String { URLRedactor.redactedURLString(value) } #endif } extension DreamioWebViewController: WKNavigationDelegate { func webView( _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { guard let url = navigationAction.request.url else { decisionHandler(.cancel) return } if shouldOpenExternally(url: url, navigationType: navigationAction.navigationType) { UIApplication.shared.open(url) decisionHandler(.cancel) return } decisionHandler(.allow) } func webView( _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void ) { #if DEBUG if let response = navigationResponse.response as? HTTPURLResponse { let url = response.url?.absoluteString ?? "unknown" print("[DreamioNavigation] status=\(response.statusCode) url=\(redactedURLString(url))") } #endif decisionHandler(.allow) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { #if DEBUG print("[DreamioNavigation] didFail url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))") #endif showLoadFailure(error) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { #if DEBUG print("[DreamioNavigation] didFailProvisional url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))") #endif showLoadFailure(error) } private func shouldOpenExternally(url: URL, navigationType: WKNavigationType) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } if ["http", "https"].contains(scheme) { return false } if ["mailto", "tel", "sms"].contains(scheme) { return true } return navigationType == .linkActivated } } extension DreamioWebViewController: WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == Constants.streamCandidateMessageHandler, let candidate = StreamCandidate(messageBody: message.body) { handleStreamCandidate(candidate) return } #if DEBUG guard message.name == Constants.diagnosticsMessageHandler, let body = message.body as? [String: Any], let type = body["type"] as? String else { return } logDiagnostic( type: type, payload: body["payload"] ?? [:], pageURL: body["href"] as? String ) #endif } } private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { weak var delegate: WKScriptMessageHandler? init(delegate: WKScriptMessageHandler) { self.delegate = delegate super.init() } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { delegate?.userContentController(userContentController, didReceive: message) } } extension DreamioWebViewController: WKUIDelegate { func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { if navigationAction.targetFrame == nil { webView.load(navigationAction.request) } return nil } }