diff --git a/Sora/Info.plist b/Sora/Info.plist index b085ddf..df1d517 100644 --- a/Sora/Info.plist +++ b/Sora/Info.plist @@ -2,8 +2,6 @@ - NSCameraUsageDescription - Sora may requires access to your device's camera. BGTaskSchedulerPermittedIdentifiers $(PRODUCT_BUNDLE_IDENTIFIER) diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 4073e20..34ababb 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -13,6 +13,10 @@ struct SoraApp: App { @StateObject private var moduleManager = ModuleManager() @StateObject private var librarykManager = LibraryManager() + init() { + registerCustomDNSGlobally() + } + var body: some Scene { WindowGroup { ContentView() diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index 947350c..d30569b 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -45,7 +45,7 @@ extension JSContext { request.setValue(value, forHTTPHeaderField: key) } } - let task = URLSession.cloudflareCustom.dataTask(with: request) { data, _, error in + let task = URLSession.customDNS.dataTask(with: request) { data, _, error in if let error = error { Logger.shared.log("Network error in fetchNativeFunction: \(error.localizedDescription)", type: "Error") reject.call(withArguments: [error.localizedDescription]) @@ -111,7 +111,7 @@ extension JSContext { } } - let task = URLSession.cloudflareCustom.downloadTask(with: request) { tempFileURL, response, error in + let task = URLSession.customDNS.downloadTask(with: request) { tempFileURL, response, error in if let error = error { Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error") reject.call(withArguments: [error.localizedDescription]) diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index d51eb38..187128c 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -5,55 +5,9 @@ // Created by Francesco on 05/01/25. // -import Network import Foundation -enum DNSProvider: String, CaseIterable, Hashable { - case cloudflare = "Cloudflare" - case google = "Google" - case openDNS = "OpenDNS" - case quad9 = "Quad9" - case adGuard = "AdGuard" - case cleanbrowsing = "CleanBrowsing" - case controld = "ControlD" - - var servers: [String] { - switch self { - case .cloudflare: - return ["1.1.1.1", "1.0.0.1"] - case .google: - return ["8.8.8.8", "8.8.4.4"] - case .openDNS: - return ["208.67.222.222", "208.67.220.220"] - case .quad9: - return ["9.9.9.9", "149.112.112.112"] - case .adGuard: - return ["94.140.14.14", "94.140.15.15"] - case .cleanbrowsing: - return ["185.228.168.168", "185.228.169.168"] - case .controld: - return ["76.76.2.0", "76.76.10.0"] - } - } -} - extension URLSession { - private static let dnsSelectorKey = "CustomDNSProvider" - - static var currentDNSProvider: DNSProvider { - get { - guard let savedProviderRawValue = UserDefaults.standard.string(forKey: dnsSelectorKey) else { - UserDefaults.standard.set(DNSProvider.cloudflare.rawValue, forKey: dnsSelectorKey) - return .cloudflare - } - - return DNSProvider(rawValue: savedProviderRawValue) ?? .cloudflare - } - set { - UserDefaults.standard.set(newValue.rawValue, forKey: dnsSelectorKey) - } - } - static let userAgents = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", @@ -80,33 +34,21 @@ extension URLSession { "Mozilla/5.0 (Android 13; Mobile; rv:122.0) Gecko/122.0 Firefox/122.0" ] - static var randomUserAgent: String { + static let randomUserAgent: String = { userAgents.randomElement() ?? userAgents[0] - } + }() - static var custom: URLSession { + static let custom: URLSession = { let configuration = URLSessionConfiguration.default - configuration.httpAdditionalHeaders = [ - "User-Agent": randomUserAgent - ] + configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent] return URLSession(configuration: configuration) - } + }() - static var cloudflareCustom: URLSession { - let configuration = URLSessionConfiguration.default - configuration.httpAdditionalHeaders = [ - "User-Agent": randomUserAgent - ] - - let dnsServers = currentDNSProvider.servers - - let dnsSettings: [AnyHashable: Any] = [ - "DNSSettings": [ - "ServerAddresses": dnsServers - ] - ] - - configuration.connectionProxyDictionary = dnsSettings - return URLSession(configuration: configuration) - } + static let customDNS: URLSession = { + let config = URLSessionConfiguration.default + var protocols = config.protocolClasses ?? [] + protocols.insert(CustomURLProtocol.self, at: 0) + config.protocolClasses = protocols + return URLSession(configuration: config, delegate: InsecureSessionDelegate(), delegateQueue: nil) + }() } diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift index 6cf5e82..8edbda2 100644 --- a/Sora/Utils/JSLoader/JSController.swift +++ b/Sora/Utils/JSLoader/JSController.swift @@ -36,7 +36,7 @@ class JSController: ObservableObject { return } - URLSession.cloudflareCustom.dataTask(with: url) { [weak self] data, _, error in + URLSession.customDNS.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return } if let error = error { @@ -77,7 +77,7 @@ class JSController: ObservableObject { return } - URLSession.cloudflareCustom.dataTask(with: url) { [weak self] data, _, error in + URLSession.customDNS.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return } if let error = error { @@ -130,7 +130,7 @@ class JSController: ObservableObject { return } - URLSession.cloudflareCustom.dataTask(with: url) { [weak self] data, _, error in + URLSession.customDNS.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return } if let error = error { @@ -430,7 +430,7 @@ class JSController: ObservableObject { func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) { let url = URL(string: episodeUrl)! - let task = URLSession.cloudflareCustom.dataTask(with: url) { data, response, error in + let task = URLSession.customDNS.dataTask(with: url) { data, response, error in if let error = error { Logger.shared.log("URLSession error: \(error.localizedDescription)", type: "Error") DispatchQueue.main.async { completion((nil, nil)) } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift index 944f2de..d457d7a 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift @@ -29,7 +29,7 @@ class VTTSubtitlesLoader: ObservableObject { let format = determineSubtitleFormat(from: url) - URLSession.cloudflareCustom.dataTask(with: url) { data, _, error in + URLSession.shared.dataTask(with: url) { data, _, error in guard let data = data, let content = String(data: data, encoding: .utf8), error == nil else { return } diff --git a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift index 143cd94..c7f6d96 100644 --- a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift +++ b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift @@ -154,7 +154,7 @@ struct ModuleAdditionSettingsView: View { return } do { - let (data, _) = try await URLSession.cloudflareCustom.data(from: url) + let (data, _) = try await URLSession.customDNS.data(from: url) let metadata = try JSONDecoder().decode(ModuleMetadata.self, from: data) await MainActor.run { self.moduleMetadata = metadata diff --git a/Sora/Utils/Modules/ModuleManager.swift b/Sora/Utils/Modules/ModuleManager.swift index ada264c..9bf815c 100644 --- a/Sora/Utils/Modules/ModuleManager.swift +++ b/Sora/Utils/Modules/ModuleManager.swift @@ -46,14 +46,14 @@ class ModuleManager: ObservableObject { throw NSError(domain: "Module already exists", code: -1) } - let (metadataData, _) = try await URLSession.cloudflareCustom.data(from: url) + let (metadataData, _) = try await URLSession.customDNS.data(from: url) let metadata = try JSONDecoder().decode(ModuleMetadata.self, from: metadataData) guard let scriptUrl = URL(string: metadata.scriptUrl) else { throw NSError(domain: "Invalid script URL", code: -1) } - let (scriptData, _) = try await URLSession.cloudflareCustom.data(from: scriptUrl) + let (scriptData, _) = try await URLSession.customDNS.data(from: scriptUrl) guard let jsContent = String(data: scriptData, encoding: .utf8) else { throw NSError(domain: "Invalid script encoding", code: -1) } @@ -94,7 +94,7 @@ class ModuleManager: ObservableObject { func refreshModules() async { for (index, module) in modules.enumerated() { do { - let (metadataData, _) = try await URLSession.cloudflareCustom.data(from: URL(string: module.metadataUrl)!) + let (metadataData, _) = try await URLSession.customDNS.data(from: URL(string: module.metadataUrl)!) let newMetadata = try JSONDecoder().decode(ModuleMetadata.self, from: metadataData) if newMetadata.version != module.metadata.version { @@ -102,7 +102,7 @@ class ModuleManager: ObservableObject { throw NSError(domain: "Invalid script URL", code: -1) } - let (scriptData, _) = try await URLSession.cloudflareCustom.data(from: scriptUrl) + let (scriptData, _) = try await URLSession.customDNS.data(from: scriptUrl) guard let jsContent = String(data: scriptData, encoding: .utf8) else { throw NSError(domain: "Invalid script encoding", code: -1) } diff --git a/Sora/Utils/NetworkDns/CustomDNS.swift b/Sora/Utils/NetworkDns/CustomDNS.swift new file mode 100644 index 0000000..ee41506 --- /dev/null +++ b/Sora/Utils/NetworkDns/CustomDNS.swift @@ -0,0 +1,248 @@ +// +// CustomDNS.swift +// Sora +// +// Created by Seiike on 26/03/25. +// + +import Foundation +import Network + +enum DNSProvider: String, CaseIterable, Hashable { + case cloudflare = "Cloudflare" + case google = "Google" + case openDNS = "OpenDNS" + case quad9 = "Quad9" + case adGuard = "AdGuard" + case cleanbrowsing = "CleanBrowsing" + case controld = "ControlD" + + var servers: [String] { + switch self { + case .cloudflare: + return ["1.1.1.1", "1.0.0.1"] + case .google: + return ["8.8.8.8", "8.8.4.4"] + case .openDNS: + return ["208.67.222.222", "208.67.220.220"] + case .quad9: + return ["9.9.9.9", "149.112.112.112"] + case .adGuard: + return ["94.140.14.14", "94.140.15.15"] + case .cleanbrowsing: + return ["185.228.168.168", "185.228.169.168"] + case .controld: + return ["76.76.2.0", "76.76.10.0"] + } + } + + static var current: DNSProvider { + get { + let raw = UserDefaults.standard.string(forKey: "SelectedDNSProvider") ?? DNSProvider.cloudflare.rawValue + return DNSProvider(rawValue: raw) ?? .cloudflare + } + set { + UserDefaults.standard.setValue(newValue.rawValue, forKey: "SelectedDNSProvider") + } + } +} + +class CustomDNSResolver { + var dnsServerIP: String { + return DNSProvider.current.servers.first ?? "1.1.1.1" + } + + func buildDNSQuery(for host: String) -> (Data, UInt16) { + var data = Data() + let queryID = UInt16.random(in: 0...UInt16.max) + data.append(UInt8(queryID >> 8)) + data.append(UInt8(queryID & 0xFF)) + data.append(contentsOf: [0x01, 0x00]) + data.append(contentsOf: [0x00, 0x01]) + data.append(contentsOf: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + let labels = host.split(separator: ".") + for label in labels { + if let labelData = label.data(using: .utf8) { + data.append(UInt8(labelData.count)) + data.append(labelData) + } + } + data.append(0) + data.append(contentsOf: [0x00, 0x01]) + data.append(contentsOf: [0x00, 0x01]) + return (data, queryID) + } + + func parseDNSResponse(_ data: Data, queryID: UInt16) -> [String] { + var ips = [String]() + var offset = 0 + func readUInt16() -> UInt16? { + guard offset + 2 <= data.count else { return nil } + let value = (UInt16(data[offset]) << 8) | UInt16(data[offset+1]) + offset += 2 + return value + } + func readUInt32() -> UInt32? { + guard offset + 4 <= data.count else { return nil } + let value = (UInt32(data[offset]) << 24) | (UInt32(data[offset+1]) << 16) | (UInt32(data[offset+2]) << 8) | UInt32(data[offset+3]) + offset += 4 + return value + } + guard data.count >= 12 else { return [] } + let responseID = (UInt16(data[0]) << 8) | UInt16(data[1]) + if responseID != queryID { return [] } + offset = 2 + offset += 2 + guard let qdCount = readUInt16() else { return [] } + guard let anCount = readUInt16() else { return [] } + offset += 4 + for _ in 0..) -> Void) { + let dnsServer = self.dnsServerIP + guard let port = NWEndpoint.Port(rawValue: 53) else { + completion(.failure(NSError(domain: "CustomDNS", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid port"]))) + return + } + let connection = NWConnection(host: NWEndpoint.Host(dnsServer), port: port, using: .udp) + connection.stateUpdateHandler = { newState in + switch newState { + case .ready: + let (queryData, queryID) = self.buildDNSQuery(for: host) + connection.send(content: queryData, completion: .contentProcessed({ error in + if let error = error { + completion(.failure(error)) + connection.cancel() + } else { + connection.receive(minimumIncompleteLength: 1, maximumLength: 512) { content, _, _, error in + if let error = error { + completion(.failure(error)) + } else if let content = content { + let ips = self.parseDNSResponse(content, queryID: queryID) + if !ips.isEmpty { + completion(.success(ips)) + } else { + completion(.failure(NSError(domain: "CustomDNS", code: 2, userInfo: [NSLocalizedDescriptionKey: "No A records found"]))) + } + } + connection.cancel() + } + } + })) + case .failed(let error): + completion(.failure(error)) + connection.cancel() + default: + break + } + } + connection.start(queue: DispatchQueue.global()) + } +} + +class CustomURLProtocol: URLProtocol { + static let resolver = CustomDNSResolver() + override class func canInit(with request: URLRequest) -> Bool { + return URLProtocol.property(forKey: "Handled", in: request) == nil + } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + override func startLoading() { + guard let url = request.url, let host = url.host else { + client?.urlProtocol(self, didFailWithError: NSError(domain: "CustomDNS", code: -1, userInfo: nil)) + return + } + CustomURLProtocol.resolver.resolve(host: host) { result in + switch result { + case .success(let ips): + guard let ip = ips.first, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + self.client?.urlProtocol(self, didFailWithError: NSError(domain: "CustomDNS", code: -2, userInfo: nil)) + return + } + components.host = ip + guard let ipURL = components.url else { + self.client?.urlProtocol(self, didFailWithError: NSError(domain: "CustomDNS", code: -3, userInfo: nil)) + return + } + guard let mutableRequest = (self.request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else { + self.client?.urlProtocol(self, didFailWithError: NSError(domain: "CustomDNS", code: -4, userInfo: nil)) + return + } + mutableRequest.url = ipURL + mutableRequest.setValue(host, forHTTPHeaderField: "Host") + URLProtocol.setProperty(true, forKey: "Handled", in: mutableRequest) + let finalRequest = mutableRequest as URLRequest + let session = URLSession.customDNS + let task = session.dataTask(with: finalRequest) { data, response, error in + if let data = data, let response = response { + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } else if let error = error { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + task.resume() + case .failure(let error): + self.client?.urlProtocol(self, didFailWithError: error) + } + } + } + override func stopLoading() {} +} + +class InsecureSessionDelegate: NSObject, URLSessionDelegate { + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, let serverTrust = challenge.protectionSpace.serverTrust { + let credential = URLCredential(trust: serverTrust) + completionHandler(.useCredential, credential) + } else { + completionHandler(.performDefaultHandling, nil) + } + } +} + +func registerCustomDNSGlobally() { + let config = URLSessionConfiguration.default + var protocols = config.protocolClasses ?? [] + protocols.insert(CustomURLProtocol.self, at: 0) + config.protocolClasses = protocols + URLSessionConfiguration.default.protocolClasses = protocols + URLSessionConfiguration.ephemeral.protocolClasses = protocols + URLSessionConfiguration.background(withIdentifier: "CustomDNSBackground").protocolClasses = protocols +} diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index c38b27b..ac91e90 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -122,6 +122,10 @@ 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 1EBA87D92D94653B00CABC28 /* NetworkDns */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = NetworkDns; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 133D7C672D2BE2500075467E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -294,6 +298,7 @@ 133D7C882D2BE2640075467E /* Modules */, 1399FAD12D3AB33D00E97C31 /* Logger */, 13D842532D45266900EBBFA6 /* Drops */, + 1EBA87D92D94653B00CABC28 /* NetworkDns */, ); path = Utils; sourceTree = ""; @@ -452,6 +457,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 1EBA87D92D94653B00CABC28 /* NetworkDns */, + ); name = Sulfur; packageProductDependencies = ( 133D7C962D2BE2AF0075467E /* Kingfisher */, @@ -701,13 +709,13 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\""; - DEVELOPMENT_TEAM = 399LMK6Q2Y; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sora/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Sora; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSCameraUsageDescription = "Sora may requires access to your device's camera."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -750,6 +758,7 @@ INFOPLIST_FILE = Sora/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Sora; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSCameraUsageDescription = "Sora may requires access to your device's camera."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9784b35..fe8d570 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,43 +1,42 @@ { - "object": { - "pins": [ - { - "package": "Drops", - "repositoryURL": "https://github.com/omaralbeik/Drops.git", - "state": { - "branch": "main", - "revision": "5824681795286c36bdc4a493081a63e64e2a064e", - "version": null - } - }, - { - "package": "FFmpeg-iOS-Lame", - "repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Lame", - "state": { - "branch": "main", - "revision": "1808fa5a1263c5e216646cd8421fc7dcb70520cc", - "version": null - } - }, - { - "package": "FFmpeg-iOS-Support", - "repositoryURL": "https://github.com/kewlbear/FFmpeg-iOS-Support", - "state": { - "branch": null, - "revision": "be3bd9149ac53760e8725652eee99c405b2be47a", - "version": "0.0.2" - } - }, - { - "package": "Kingfisher", - "repositoryURL": "https://github.com/onevcat/Kingfisher.git", - "state": { - "branch": null, - "revision": "b6f62758f21a8c03cd64f4009c037cfa580a256e", - "version": "7.9.1" - } + "originHash" : "28f2c123747ea3d0aee96430294eb72e7254bafd504c83303b2a2e02f270f26f", + "pins" : [ + { + "identity" : "drops", + "kind" : "remoteSourceControl", + "location" : "https://github.com/omaralbeik/Drops.git", + "state" : { + "branch" : "main", + "revision" : "5824681795286c36bdc4a493081a63e64e2a064e" } - ] - }, - "version": 1 + }, + { + "identity" : "ffmpeg-ios-lame", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kewlbear/FFmpeg-iOS-Lame", + "state" : { + "branch" : "main", + "revision" : "1808fa5a1263c5e216646cd8421fc7dcb70520cc" + } + }, + { + "identity" : "ffmpeg-ios-support", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kewlbear/FFmpeg-iOS-Support", + "state" : { + "revision" : "be3bd9149ac53760e8725652eee99c405b2be47a", + "version" : "0.0.2" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "b6f62758f21a8c03cd64f4009c037cfa580a256e", + "version" : "7.9.1" + } + } + ], + "version" : 3 }