fix native playback stream resolution

This commit is contained in:
dirtydishes 2026-05-24 23:41:30 -04:00
parent b15e4d640e
commit d46004a98e
11 changed files with 588 additions and 16 deletions

View file

@ -4,3 +4,4 @@
{"id":"int-a86e17e0","kind":"field_change","created_at":"2026-05-25T02:34:54.605755Z","actor":"dirtydishes","issue_id":"dreamio-evt","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation."}}
{"id":"int-4d73c126","kind":"field_change","created_at":"2026-05-25T03:20:17.439589Z","actor":"dirtydishes","issue_id":"dreamio-l68","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK."}}
{"id":"int-3dbe205a","kind":"field_change","created_at":"2026-05-25T03:23:00.515861Z","actor":"dirtydishes","issue_id":"dreamio-2lp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed Swift raw string escaping and guarded MobileVLCKit import for builds before pod install."}}
{"id":"int-23df9e14","kind":"field_change","created_at":"2026-05-25T03:41:03.811099Z","actor":"dirtydishes","issue_id":"dreamio-vxs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved native playback stream URLs before opening VLC, added resolver selection tests, and documented validation limits."}}

View file

@ -1,3 +1,4 @@
{"_type":"issue","id":"dreamio-vxs","title":"Resolve final media URLs before native playback","description":"Dreamio native playback can pass addon resolver URLs into VLC instead of the final direct media URL. Resolve known Stremio addon stream responses before presenting the native player, preserve needed headers, and make startup failure recoverable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:36:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:41:04Z","started_at":"2026-05-25T03:36:19Z","closed_at":"2026-05-25T03:41:04Z","close_reason":"Resolved native playback stream URLs before opening VLC, added resolver selection tests, and documented validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-2lp","title":"Fix native playback build blockers","description":"Correct Swift string escaping for the injected stream bridge and allow the VLC backend source to compile before MobileVLCKit is installed by guarding the import with canImport.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:22:52Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:23:00Z","started_at":"2026-05-25T03:23:00Z","closed_at":"2026-05-25T03:23:00Z","close_reason":"Fixed Swift raw string escaping and guarded MobileVLCKit import for builds before pod install.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -14,6 +14,7 @@
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */; };
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; };
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -26,6 +27,7 @@
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlaybackBackend.swift; sourceTree = "<group>"; };
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = "<group>"; };
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -62,6 +64,7 @@
6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */,
6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */,
6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */,
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
@ -142,6 +145,7 @@
6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */,
6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */,
6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */,
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,

View file

@ -52,6 +52,7 @@ final class DreamioWebViewController: UIViewController {
private var progressObservation: NSKeyValueObservation?
private var userAgent: String?
private var lastNativePlaybackURL: URL?
private let streamResolver: StreamResolving = StremioStreamResolver()
private static let streamCandidateScript = WKUserScript(
source: #"""
@ -103,6 +104,7 @@ final class DreamioWebViewController: UIViewController {
if (!looksNative(url)) {
return;
}
stopNativeHandledMedia(element);
try {
window.webkit.messageHandlers.dreamioStreamCandidate.postMessage({
url,
@ -114,6 +116,23 @@ final class DreamioWebViewController: UIViewController {
} catch (_) {}
};
const stopNativeHandledMedia = (element) => {
const media = element instanceof HTMLVideoElement
? element
: element && element.parentElement instanceof HTMLVideoElement
? element.parentElement
: null;
if (!media) {
return;
}
try { media.pause(); } catch (_) {}
try { media.removeAttribute("src"); } catch (_) {}
try {
media.querySelectorAll("source").forEach((source) => source.removeAttribute("src"));
} catch (_) {}
try { media.load(); } catch (_) {}
};
const inspectMedia = (node) => {
if (!node) {
return;
@ -331,21 +350,61 @@ final class DreamioWebViewController: UIViewController {
return
}
if lastNativePlaybackURL == request.playbackURL {
let duplicateKey = request.resolverURL ?? request.playbackURL
if lastNativePlaybackURL == duplicateKey {
return
}
lastNativePlaybackURL = request.playbackURL
lastNativePlaybackURL = duplicateKey
#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)
Task { [weak self] in
await self?.resolveAndPresentNativePlayback(request)
}
}
@MainActor
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
do {
let resolved = try await streamResolver.resolve(request: request)
#if DEBUG
print("[DreamioStreamResolver] source=\(resolved.source) playback=\(URLRedactor.redactedURLString(resolved.playbackURL.absoluteString))")
#endif
let resolvedRequest = NativePlaybackRequest(
playbackURL: resolved.playbackURL,
observedURL: request.observedURL,
resolverURL: request.resolverURL,
pageURL: request.pageURL,
userAgent: request.userAgent,
referer: request.referer,
headers: resolved.headers,
classification: request.classification
)
let player = NativePlayerViewController(request: resolvedRequest)
player.onDismiss = { [weak self] in
self?.lastNativePlaybackURL = nil
}
present(player, animated: true)
} catch {
#if DEBUG
print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
#endif
lastNativePlaybackURL = nil
showNativePlaybackResolutionFailure(error)
}
}
private func showNativePlaybackResolutionFailure(_ error: Error) {
let alert = UIAlertController(
title: "Could not open stream",
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Close", style: .cancel))
present(alert, animated: true)
}
#if DEBUG

View file

@ -12,11 +12,17 @@ protocol NativePlaybackBackend: AnyObject {
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
case playbackFailed
var errorDescription: String? {
switch self {
case .backendUnavailable:
return "Native playback is not available in this build."
case .startupTimedOut:
return "Native playback did not start before the timeout."
case .playbackFailed:
return "VLC reported a playback error for this stream."
}
}
}

View file

@ -3,6 +3,7 @@ import UIKit
final class NativePlayerViewController: UIViewController {
private let request: NativePlaybackRequest
private var backend: NativePlaybackBackend
private var startupTimer: Timer?
var onDismiss: (() -> Void)?
private let loadingView: UIActivityIndicatorView = {
@ -66,11 +67,13 @@ final class NativePlayerViewController: UIViewController {
view.backgroundColor = .black
configureBackend()
configureLayout()
startStartupTimer()
backend.play(request: request)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
startupTimer?.invalidate()
backend.stop()
onDismiss?()
}
@ -80,17 +83,27 @@ final class NativePlayerViewController: UIViewController {
backend.view.translatesAutoresizingMaskIntoConstraints = false
backend.onReady = { [weak self] in
DispatchQueue.main.async {
self?.startupTimer?.invalidate()
self?.loadingView.stopAnimating()
self?.loadingView.isHidden = true
}
}
backend.onFailure = { [weak self] error in
DispatchQueue.main.async {
self?.startupTimer?.invalidate()
self?.showFailure(error)
}
}
}
private func startStartupTimer() {
startupTimer?.invalidate()
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in
self?.backend.stop()
self?.showFailure(NativePlaybackError.startupTimedOut)
}
}
private func configureLayout() {
view.addSubview(backend.view)
view.addSubview(loadingView)

View file

@ -24,6 +24,7 @@ struct NativePlaybackRequest {
let pageURL: URL?
let userAgent: String?
let referer: String
let headers: [String: String]
let classification: StreamClassification
}
@ -75,16 +76,40 @@ enum StreamClassifier {
}
return NativePlaybackRequest(
playbackURL: candidate.resolverURL ?? candidate.observedURL,
playbackURL: candidate.observedURL,
observedURL: candidate.observedURL,
resolverURL: candidate.resolverURL,
pageURL: candidate.pageURL,
userAgent: userAgent,
referer: referer,
headers: Self.defaultHeaders(userAgent: userAgent),
classification: classification
)
}
static func defaultHeaders(userAgent: String?) -> [String: String] {
var headers = ["Referer": referer]
if let userAgent, !userAgent.isEmpty {
headers["User-Agent"] = userAgent
}
return headers
}
static func isDirectPlayableFileURL(_ url: URL) -> Bool {
let container = containerGuess(for: url, resolverURL: nil)
return [.mp4, .mkv, .avi, .webm].contains(container)
}
static func isWebKitCompatibleURL(_ url: URL) -> Bool {
let container = containerGuess(for: url, resolverURL: nil)
return container == .hls || container == .mp4
}
static func isKnownResolverURL(_ url: URL) -> Bool {
matches(url, host: "addon.debridio.com", pathPrefix: "/play/")
|| matches(url, host: "torrentio.strem.fun", pathPrefix: "/resolve/")
}
static func classify(candidate: StreamCandidate) -> StreamClassification {
let observed = candidate.observedURL
let resolver = candidate.resolverURL

View file

@ -0,0 +1,167 @@
import Foundation
struct ResolvedNativeStream {
let playbackURL: URL
let headers: [String: String]
let source: String
}
enum StreamResolverError: LocalizedError {
case noResolverURL
case httpStatus(Int)
case emptyResponse
case invalidResponse
case noPlayableStream
var errorDescription: String? {
switch self {
case .noResolverURL:
return "Dreamio could not find an addon resolver URL for this stream."
case let .httpStatus(status):
return "The stream resolver returned HTTP \(status)."
case .emptyResponse:
return "The stream resolver returned an empty response."
case .invalidResponse:
return "The stream resolver returned data Dreamio could not parse."
case .noPlayableStream:
return "The resolver did not return a direct playable media URL."
}
}
}
protocol StreamResolving {
func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream
}
final class StremioStreamResolver: StreamResolving {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream {
if StreamClassifier.isDirectPlayableFileURL(request.observedURL) {
return ResolvedNativeStream(
playbackURL: request.observedURL,
headers: request.headers,
source: "observed-direct-file"
)
}
let possibleResolverURL = request.resolverURL ?? request.observedURL
guard StreamClassifier.isKnownResolverURL(possibleResolverURL) else {
throw StreamResolverError.noResolverURL
}
var urlRequest = URLRequest(url: possibleResolverURL)
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
request.headers.forEach { key, value in
urlRequest.setValue(value, forHTTPHeaderField: key)
}
let (data, response) = try await session.data(for: urlRequest)
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
throw StreamResolverError.httpStatus(httpResponse.statusCode)
}
if let finalURL = response.url, StreamClassifier.isDirectPlayableFileURL(finalURL) {
return ResolvedNativeStream(
playbackURL: finalURL,
headers: request.headers,
source: "resolver-redirect"
)
}
guard !data.isEmpty else {
throw StreamResolverError.emptyResponse
}
let payload = try parsePayload(from: data)
guard let stream = Self.bestPlayableStream(in: payload, fallbackHeaders: request.headers) else {
throw StreamResolverError.noPlayableStream
}
return stream
}
private func parsePayload(from data: Data) throws -> Any {
do {
return try JSONSerialization.jsonObject(with: data)
} catch {
throw StreamResolverError.invalidResponse
}
}
static func bestPlayableStream(in payload: Any, fallbackHeaders: [String: String]) -> ResolvedNativeStream? {
let streams = streamDictionaries(in: payload)
let candidates = streams.compactMap { stream -> ResolvedNativeStream? in
guard let url = directURL(in: stream) else {
return nil
}
guard StreamClassifier.isDirectPlayableFileURL(url) else {
return nil
}
return ResolvedNativeStream(
playbackURL: url,
headers: mergedHeaders(fallbackHeaders: fallbackHeaders, stream: stream),
source: "resolver-json"
)
}
return candidates.first { !StreamClassifier.isWebKitCompatibleURL($0.playbackURL) } ?? candidates.first
}
private static func streamDictionaries(in payload: Any) -> [[String: Any]] {
if let dictionary = payload as? [String: Any],
let streams = dictionary["streams"] as? [[String: Any]] {
return streams
}
if let streams = payload as? [[String: Any]] {
return streams
}
return []
}
private static func directURL(in stream: [String: Any]) -> URL? {
let fields = ["url", "externalUrl", "externalURL", "file", "streamUrl", "streamURL"]
for field in fields {
if let value = stream[field] as? String,
let url = URL(string: value),
["http", "https"].contains(url.scheme?.lowercased()) {
return url
}
}
return nil
}
private static func mergedHeaders(fallbackHeaders: [String: String], stream: [String: Any]) -> [String: String] {
var headers = fallbackHeaders
headerDictionaries(in: stream).forEach { headerDictionary in
headerDictionary.forEach { key, value in
headers[key] = value
}
}
return headers
}
private static func headerDictionaries(in stream: [String: Any]) -> [[String: String]] {
var dictionaries: [[String: String]] = []
if let headers = stream["headers"] as? [String: String] {
dictionaries.append(headers)
}
if let requestHeaders = stream["requestHeaders"] as? [String: String] {
dictionaries.append(requestHeaders)
}
if let behaviorHints = stream["behaviorHints"] as? [String: Any] {
if let headers = behaviorHints["headers"] as? [String: String] {
dictionaries.append(headers)
}
if let proxyHeaders = behaviorHints["proxyHeaders"] as? [String: Any],
let request = proxyHeaders["request"] as? [String: String] {
dictionaries.append(request)
}
}
return dictionaries
}
}

View file

@ -30,21 +30,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit)
let media = VLCMedia(url: request.playbackURL)
var headers = ["Referer": request.referer]
if let userAgent = request.userAgent {
headers["User-Agent"] = userAgent
}
let headerValue = headers
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
.joined(separator: "\r\n")
media.addOption(":http-referrer=\(request.referer)")
if let userAgent = request.userAgent {
media.addOption(":http-user-agent=\(userAgent)")
}
if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)")
}
mediaPlayer.media = media
#if DEBUG
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
mediaPlayer.play()
#else
onFailure?(NativePlaybackError.backendUnavailable)
@ -54,6 +54,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
func stop() {
#if canImport(MobileVLCKit)
mediaPlayer.stop()
mediaPlayer.drawable = nil
mediaPlayer.media = nil
#endif
}
@ -62,14 +63,40 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
#if canImport(MobileVLCKit)
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) {
#if DEBUG
print("[DreamioVLC] state=\(stateName(mediaPlayer.state))")
#endif
switch mediaPlayer.state {
case .opening, .buffering, .playing:
case .buffering, .playing:
onReady?()
case .error:
onFailure?(NativePlaybackError.backendUnavailable)
onFailure?(NativePlaybackError.playbackFailed)
default:
break
}
}
private func stateName(_ state: VLCMediaPlayerState) -> String {
switch state {
case .opening:
return "opening"
case .buffering:
return "buffering"
case .playing:
return "playing"
case .ended:
return "ended"
case .stopped:
return "stopped"
case .error:
return "error"
case .paused:
return "paused"
case .esAdded:
return "elementary-stream-added"
@unknown default:
return "unknown"
}
}
}
#endif

View file

@ -0,0 +1,73 @@
import Foundation
@main
struct StreamResolverTests {
static func main() {
testClassifierPrefersObservedDirectFile()
testResolverSelectsUnsupportedDirectURLAndHeaders()
testResolverRejectsHLSOnlyResponse()
print("StreamResolverTests passed")
}
private static func testClassifierPrefersObservedDirectFile() {
let body: [String: Any] = [
"url": "https://cdn.example.test/movie.mkv?token=secret",
"resolverUrl": "https://addon.debridio.com/play/example"
]
let candidate = StreamCandidate(messageBody: body)!
let request = StreamClassifier.playbackRequest(from: candidate, userAgent: "DreamioTest/1")!
assertEqual(request.playbackURL.absoluteString, "https://cdn.example.test/movie.mkv?token=secret")
assertEqual(request.headers["Referer"], "https://web.stremio.com/")
assertEqual(request.headers["User-Agent"], "DreamioTest/1")
}
private static func testResolverSelectsUnsupportedDirectURLAndHeaders() {
let payload: [String: Any] = [
"streams": [
[
"url": "https://cdn.example.test/trailer.mp4"
],
[
"externalUrl": "https://cdn.example.test/movie.mkv?signature=secret",
"behaviorHints": [
"proxyHeaders": [
"request": [
"Referer": "https://resolver.example.test/",
"User-Agent": "ResolverAgent/1"
]
]
]
]
]
]
let stream = StremioStreamResolver.bestPlayableStream(
in: payload,
fallbackHeaders: ["Referer": "https://web.stremio.com/"]
)!
assertEqual(stream.playbackURL.absoluteString, "https://cdn.example.test/movie.mkv?signature=secret")
assertEqual(stream.headers["Referer"], "https://resolver.example.test/")
assertEqual(stream.headers["User-Agent"], "ResolverAgent/1")
}
private static func testResolverRejectsHLSOnlyResponse() {
let payload: [String: Any] = [
"streams": [
["url": "https://cdn.example.test/live.m3u8"]
]
]
let stream = StremioStreamResolver.bestPlayableStream(
in: payload,
fallbackHeaders: ["Referer": "https://web.stremio.com/"]
)
assert(stream == nil, "Expected HLS-only resolver response to stay out of native playback")
}
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
}
}

File diff suppressed because one or more lines are too long