element in the DOM.
46 | public class HTMLTableRow : HTMLElement {
47 |
48 | /// Returns all columns within this row.
49 | public var columns : [HTMLTableColumn]? {
50 | return children()
51 | }
52 |
53 | //========================================
54 | // MARK: Overrides
55 | //========================================
56 |
57 | internal override class func createXPathQuery(_ parameters: String) -> String {
58 | return "//tr\(parameters)"
59 | }
60 | }
61 |
62 | /// HTML Table Column class, which represents the element in the DOM.
63 | public class HTMLTableColumn : HTMLElement {
64 |
65 | //========================================
66 | // MARK: Overrides
67 | //========================================
68 |
69 | internal override class func createXPathQuery(_ parameters: String) -> String {
70 | return "//td\(parameters)"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Sources/WKZombie/JSONPage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // JSONPage.swift
3 | //
4 | // Copyright (c) 2015 Mathias Koehnke (http://www.mathiaskoehnke.de)
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 |
24 | import Foundation
25 |
26 | /**
27 | * Protocol, which must be implemented by JSON models in order to get decoded.
28 | */
29 | public protocol JSONDecodable {
30 | /**
31 | Returns the decoded JSON data represented as an model object.
32 |
33 | - parameter json: The JSON data.
34 |
35 | - returns: The model object.
36 | */
37 | static func decode(_ json: JSONElement) -> Self?
38 | }
39 |
40 | /**
41 | * Protocol, which must be implemented by objects in order to get parsed as JSON.
42 | */
43 | public protocol JSONParsable {
44 | /**
45 | Returns the parsable JSON data.
46 |
47 | - returns: The JSON data.
48 | */
49 | func content() -> JSON?
50 | }
51 |
52 |
53 | /// JSONPage class, which represents the entire JSON document.
54 | public class JSONPage : JSONParser, Page, JSONParsable {
55 |
56 | /**
57 | Returns a JSON page instance for the specified JSON data.
58 |
59 | - parameter data: The JSON data.
60 | - parameter url: The URL of the page.
61 |
62 | - returns: A JSON page.
63 | */
64 | public static func pageWithData(_ data: Data?, url: URL?) -> Page? {
65 | if let data = data {
66 | return JSONPage(data: data, url: url)
67 | }
68 | return nil
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Sources/WKZombie/Logger.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Logger.swift
3 | //
4 | // Copyright (c) 2016 Mathias Koehnke (http://www.mathiaskoehnke.de)
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 |
24 | import Foundation
25 |
26 | /// WKZombie Console Logger
27 | public class Logger {
28 |
29 | public static var enabled : Bool = true
30 |
31 | public class func log(_ message: String, lineBreak: Bool = true) {
32 | if enabled {
33 | if lineBreak {
34 | print("\(message)")
35 | } else {
36 | print("\(message)", terminator: "")
37 | }
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/WKZombie/Page.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Page.swift
3 | //
4 | // Copyright (c) 2015 Mathias Koehnke (http://www.mathiaskoehnke.de)
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 |
24 | import Foundation
25 |
26 | /**
27 | * Protocol, which is implemented by the HTMLPage and JSONPage classes.
28 | */
29 | public protocol Page {
30 | /**
31 | Returns a (HTML or JSON) page instance for the specified data.
32 |
33 | - parameter data: The encoded data.
34 | - parameter url: The URL of the page.
35 |
36 | - returns: A HTML or JSON page.
37 | */
38 | static func pageWithData(_ data: Data?, url: URL?) -> Page?
39 | }
40 |
41 |
42 |
--------------------------------------------------------------------------------
/Sources/WKZombie/Parser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Parser.swift
3 | //
4 | // Copyright (c) 2015 Mathias Koehnke (http://www.mathiaskoehnke.de)
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 |
24 | import Foundation
25 | import hpple
26 |
27 | /// Base class for the HTMLParser and JSONParser.
28 | public class Parser : CustomStringConvertible {
29 |
30 | /// The URL of the page.
31 | public fileprivate(set) var url : URL?
32 |
33 | /**
34 | Returns a (HTML or JSON) parser instance for the specified data.
35 |
36 | - parameter data: The encoded data.
37 | - parameter url: The URL of the page.
38 |
39 | - returns: A HTML or JSON page.
40 | */
41 | required public init(data: Data, url: URL? = nil) {
42 | self.url = url
43 | }
44 |
45 | public var description: String {
46 | return "\(type(of: self))"
47 | }
48 | }
49 |
50 | //========================================
51 | // MARK: HTML
52 | //========================================
53 |
54 | /// A HTML Parser class, which wraps the functionality of the TFHpple class.
55 | public class HTMLParser : Parser {
56 |
57 | fileprivate var doc : TFHpple?
58 |
59 | required public init(data: Data, url: URL? = nil) {
60 | super.init(data: data, url: url)
61 | self.doc = TFHpple(htmlData: data)
62 | }
63 |
64 | public func searchWithXPathQuery(_ xPathOrCSS: String) -> [AnyObject]? {
65 | return doc?.search(withXPathQuery: xPathOrCSS) as [AnyObject]?
66 | }
67 |
68 | public var data: Data? {
69 | return doc?.data
70 | }
71 |
72 | override public var description: String {
73 | return (NSString(data: doc?.data ?? Data(), encoding: String.Encoding.utf8.rawValue) ?? "") as String
74 | }
75 | }
76 |
77 | /// A HTML Parser Element class, which wraps the functionality of the TFHppleElement class.
78 | public class HTMLParserElement : CustomStringConvertible {
79 | fileprivate var element : TFHppleElement?
80 | public internal(set) var XPathQuery : String?
81 |
82 | required public init?(element: AnyObject, XPathQuery : String? = nil) {
83 | if let element = element as? TFHppleElement {
84 | self.element = element
85 | self.XPathQuery = XPathQuery
86 | } else {
87 | return nil
88 | }
89 | }
90 |
91 | public var innerContent : String? {
92 | return element?.raw as String?
93 | }
94 |
95 | public var text : String? {
96 | return element?.text() as String?
97 | }
98 |
99 | public var content : String? {
100 | return element?.content as String?
101 | }
102 |
103 | public var tagName : String? {
104 | return element?.tagName as String?
105 | }
106 |
107 | public func objectForKey(_ key: String) -> String? {
108 | return element?.object(forKey: key.lowercased()) as String?
109 | }
110 |
111 | public func childrenWithTagName(_ tagName: String) -> [T]? {
112 | return element?.children(withTagName: tagName).flatMap { T(element: $0 as AnyObject) }
113 | }
114 |
115 | public func children() -> [T]? {
116 | return element?.children.flatMap { T(element:$0 as AnyObject) }
117 | }
118 |
119 | public func hasChildren() -> Bool {
120 | return element?.hasChildren() ?? false
121 | }
122 |
123 | public var description : String {
124 | return element?.raw ?? ""
125 | }
126 | }
127 |
128 |
129 | //========================================
130 | // MARK: JSON
131 | //========================================
132 |
133 | /// A JSON Parser class, which represents a JSON document.
134 | public class JSONParser : Parser {
135 |
136 | fileprivate var json : JSON?
137 |
138 | required public init(data: Data, url: URL? = nil) {
139 | super.init(data: data, url: url)
140 | let result : Result = parseJSON(data)
141 | switch result {
142 | case .success(let json): self.json = json
143 | case .error: Logger.log("Error parsing JSON!")
144 | }
145 | }
146 |
147 | public func content() -> JSON? {
148 | return json
149 | }
150 |
151 | override public var description : String {
152 | return "\(String(describing: json))"
153 | }
154 | }
155 |
156 |
157 |
158 |
--------------------------------------------------------------------------------
/Sources/WKZombie/RenderOperation.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RenderOperation.swift
3 | //
4 | // Copyright (c) 2015 Mathias Koehnke (http://www.mathiaskoehnke.de)
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 |
24 | import Foundation
25 | import WebKit
26 |
27 | //========================================
28 | // MARK: RenderOperation
29 | //========================================
30 |
31 | typealias RequestBlock = (_ operation: RenderOperation) -> Void
32 |
33 | internal class RenderOperation : Operation {
34 |
35 | fileprivate(set) weak var webView : WKWebView?
36 | fileprivate var timeout : Timer?
37 | fileprivate let timeoutInSeconds : TimeInterval
38 | fileprivate var stopRunLoop : Bool = false
39 |
40 | var loadMediaContent : Bool = true
41 | var showNetworkActivity : Bool = true
42 | var requestBlock : RequestBlock?
43 | var authenticationBlock : AuthenticationHandler?
44 | var postAction: PostAction = .none
45 |
46 | internal fileprivate(set) var result : Data?
47 | internal fileprivate(set) var response : URLResponse?
48 | internal fileprivate(set) var error : Error?
49 |
50 | fileprivate var _executing: Bool = false
51 | override var isExecuting: Bool {
52 | get {
53 | return _executing
54 | }
55 | set {
56 | if _executing != newValue {
57 | willChangeValue(forKey: "isExecuting")
58 | _executing = newValue
59 | didChangeValue(forKey: "isExecuting")
60 | }
61 | }
62 | }
63 |
64 | fileprivate var _finished: Bool = false;
65 | override var isFinished: Bool {
66 | get {
67 | return _finished
68 | }
69 | set {
70 | if _finished != newValue {
71 | willChangeValue(forKey: "isFinished")
72 | _finished = newValue
73 | didChangeValue(forKey: "isFinished")
74 | }
75 | }
76 | }
77 |
78 | init(webView: WKWebView, timeoutInSeconds : TimeInterval = 30.0) {
79 | self.timeoutInSeconds = timeoutInSeconds
80 | super.init()
81 | self.webView = webView
82 | }
83 |
84 | override func start() {
85 | if self.isCancelled {
86 | return
87 | } else {
88 | Logger.log("\(name ?? String())")
89 | Logger.log("[", lineBreak: false)
90 | isExecuting = true
91 | startTimeout()
92 |
93 | // Wait for WKWebView to finish loading before starting the operation.
94 | wait {
95 | guard let webView = webView else {
96 | return false
97 | }
98 | var isLoading = false
99 | dispatch_sync_on_main_thread {
100 | isLoading = webView.isLoading
101 | }
102 | return !isLoading
103 | }
104 |
105 | setupReferences()
106 | requestBlock?(self)
107 |
108 | // Loading
109 | wait { [unowned self] in self.stopRunLoop }
110 | }
111 | }
112 |
113 | func wait(_ condition: () -> Bool) {
114 | let updateInterval : TimeInterval = 0.1
115 | var loopUntil = Date(timeIntervalSinceNow: updateInterval)
116 | while condition() == false && RunLoop.current.run(mode: RunLoopMode.defaultRunLoopMode, before: loopUntil) {
117 | loopUntil = Date(timeIntervalSinceNow: updateInterval)
118 | Logger.log(".", lineBreak: false)
119 | }
120 | }
121 |
122 | func completeRendering(_ webView: WKWebView?, result: Data? = nil, error: Error? = nil) {
123 | stopTimeout()
124 |
125 | if isExecuting == true && isFinished == false {
126 | self.result = result ?? self.result
127 | self.error = error ?? self.error
128 |
129 | cleanupReferences()
130 |
131 | isExecuting = false
132 | isFinished = true
133 |
134 | Logger.log("]\n")
135 | }
136 | }
137 |
138 | override func cancel() {
139 | Logger.log("Cancelling Rendering - \(String(describing: name))")
140 | super.cancel()
141 | stopTimeout()
142 | cleanupReferences()
143 | isExecuting = false
144 | isFinished = true
145 | }
146 |
147 | // MARK: Helper Methods
148 |
149 | fileprivate func startTimeout() {
150 | stopRunLoop = false
151 | timeout = Timer(timeInterval: timeoutInSeconds, target: self, selector: #selector(RenderOperation.cancel), userInfo: nil, repeats: false)
152 | RunLoop.current.add(timeout!, forMode: RunLoopMode.defaultRunLoopMode)
153 | }
154 |
155 | fileprivate func stopTimeout() {
156 | timeout?.invalidate()
157 | timeout = nil
158 | stopRunLoop = true
159 | }
160 |
161 | fileprivate func setupReferences() {
162 | dispatch_sync_on_main_thread {
163 | webView?.configuration.userContentController.add(self, name: "doneLoading")
164 | webView?.navigationDelegate = self
165 | }
166 | }
167 |
168 | fileprivate func cleanupReferences() {
169 | dispatch_sync_on_main_thread {
170 | webView?.navigationDelegate = nil
171 | webView?.configuration.userContentController.removeScriptMessageHandler(forName: "doneLoading")
172 | webView = nil
173 | authenticationBlock = nil
174 | }
175 | }
176 | }
177 |
178 | //========================================
179 | // MARK: WKScriptMessageHandler
180 | //========================================
181 |
182 | extension RenderOperation : WKScriptMessageHandler {
183 |
184 | func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
185 | //None of the content loaded after this point is necessary (images, videos, etc.)
186 | if let webView = message.webView {
187 | if message.name == "doneLoading" && loadMediaContent == false {
188 | if let url = webView.url , response == nil {
189 | response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)
190 | }
191 | webView.stopLoading()
192 | self.webView(webView, didFinish: nil)
193 | }
194 | }
195 | }
196 | }
197 |
198 | //========================================
199 | // MARK: WKNavigationDelegate
200 | //========================================
201 |
202 | extension RenderOperation : WKNavigationDelegate {
203 |
204 | private func setNetworkActivityIndicatorVisible(visible : Bool) {
205 | #if os(iOS)
206 | if showNetworkActivity { UIApplication.shared.isNetworkActivityIndicatorVisible = visible }
207 | #endif
208 | }
209 |
210 | func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
211 | setNetworkActivityIndicatorVisible(visible: showNetworkActivity)
212 | }
213 |
214 | func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
215 | response = navigationResponse.response
216 | decisionHandler(.allow)
217 | }
218 |
219 | func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
220 | setNetworkActivityIndicatorVisible(visible: false)
221 | if let response = response as? HTTPURLResponse, let _ = completionBlock {
222 | let successRange = 200..<300
223 | if !successRange.contains(response.statusCode) {
224 | self.error = error
225 | self.completeRendering(webView)
226 | }
227 | }
228 | Logger.log(error.localizedDescription)
229 | }
230 |
231 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
232 | setNetworkActivityIndicatorVisible(visible: false)
233 | switch postAction {
234 | case .wait, .validate: handlePostAction(postAction, webView: webView)
235 | case .none: finishedLoading(webView)
236 | }
237 | }
238 |
239 | func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
240 | if let authenticationBlock = authenticationBlock {
241 | let authenticationResult = authenticationBlock(challenge)
242 | completionHandler(authenticationResult.0, authenticationResult.1)
243 | } else {
244 | completionHandler(.performDefaultHandling, nil)
245 | }
246 | }
247 |
248 | func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
249 | self.error = error
250 | self.completeRendering(webView)
251 | Logger.log(error.localizedDescription)
252 | }
253 | }
254 |
255 | //========================================
256 | // MARK: Validation
257 | //========================================
258 |
259 | extension RenderOperation {
260 |
261 | func finishedLoading(_ webView: WKWebView) {
262 | webView.evaluateJavaScript("\(Renderer.scrapingCommand);") { [weak self] result, error in
263 | self?.result = (result as? String)?.data(using: String.Encoding.utf8)
264 | self?.completeRendering(webView)
265 | }
266 | }
267 |
268 | func validate(_ condition: String, webView: WKWebView) {
269 | if isFinished == false && isCancelled == false {
270 | webView.evaluateJavaScript(condition) { [weak self] result, error in
271 | if let result = result as? Bool , result == true {
272 | self?.finishedLoading(webView)
273 | } else {
274 | delay(0.5, completion: {
275 | self?.validate(condition, webView: webView)
276 | })
277 | }
278 | }
279 | }
280 | }
281 |
282 | func waitAndFinish(_ time: TimeInterval, webView: WKWebView) {
283 | delay(time) {
284 | self.finishedLoading(webView)
285 | }
286 | }
287 |
288 | func handlePostAction(_ postAction: PostAction, webView: WKWebView) {
289 | switch postAction {
290 | case .validate(let script): validate(script, webView: webView)
291 | case .wait(let time): waitAndFinish(time, webView: webView)
292 | default: Logger.log("Something went wrong!")
293 | }
294 | self.postAction = .none
295 | }
296 |
297 | }
298 |
299 |
--------------------------------------------------------------------------------
/Sources/WKZombie/Renderer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Renderer.swift
3 | //
4 | // Copyright (c) 2015 Mathias Koehnke (http://www.mathiaskoehnke.de)
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 |
24 | import Foundation
25 | import WebKit
26 |
27 |
28 | typealias RenderCompletion = (_ result : Any?, _ response: URLResponse?, _ error: Error?) -> Void
29 |
30 | internal class Renderer {
31 |
32 | var loadMediaContent : Bool = true
33 |
34 | @available(OSX 10.11, *)
35 | var userAgent : String? {
36 | get {
37 | return self.webView.customUserAgent
38 | }
39 | set {
40 | self.webView.customUserAgent = newValue
41 | }
42 | }
43 |
44 | var timeoutInSeconds : TimeInterval = 30.0
45 |
46 | var showNetworkActivity : Bool = true
47 |
48 | internal static let scrapingCommand = "document.documentElement.outerHTML"
49 |
50 | internal var authenticationHandler : AuthenticationHandler?
51 |
52 | fileprivate var renderQueue : OperationQueue = {
53 | let instance = OperationQueue()
54 | instance.maxConcurrentOperationCount = 1
55 | instance.qualityOfService = .userInitiated
56 | return instance
57 | }()
58 |
59 | fileprivate var webView : WKWebView!
60 |
61 |
62 | init(processPool: WKProcessPool? = nil) {
63 | let doneLoadingWithoutMediaContentScript = "window.webkit.messageHandlers.doneLoading.postMessage(\(Renderer.scrapingCommand));"
64 | let doneLoadingUserScript = WKUserScript(source: doneLoadingWithoutMediaContentScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
65 |
66 | let getElementByXPathScript = "function getElementByXpath(path) { " +
67 | " return document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; " +
68 | "}"
69 | let getElementUserScript = WKUserScript(source: getElementByXPathScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
70 |
71 | let contentController = WKUserContentController()
72 | contentController.addUserScript(doneLoadingUserScript)
73 | contentController.addUserScript(getElementUserScript)
74 |
75 | let config = WKWebViewConfiguration()
76 | config.processPool = processPool ?? WKProcessPool()
77 | config.userContentController = contentController
78 |
79 | /// Note: The WKWebView behaves very unreliable when rendering offscreen
80 | /// on a device. This is especially true with JavaScript, which simply
81 | /// won't be executed sometimes.
82 | /// Therefore, I decided to add this very ugly hack where the rendering
83 | /// webview will be added to the view hierarchy (between the
84 | /// rootViewController's view and the key window.
85 | /// Until there's no better solution, we'll have to roll with this.
86 | dispatch_sync_on_main_thread {
87 | let warning = "The keyWindow or contentView is missing."
88 | #if os(iOS)
89 | let bounds = UIScreen.main.bounds
90 | self.webView = WKWebView(frame: bounds, configuration: config)
91 | if let window = UIApplication.shared.keyWindow {
92 | self.webView.alpha = 0.01
93 | window.insertSubview(self.webView, at: 0)
94 | } else {
95 | Logger.log(warning)
96 | }
97 | #elseif os(OSX)
98 | self.webView = WKWebView(frame: CGRect.zero, configuration: config)
99 | if let window = NSApplication.shared.keyWindow, let view = window.contentView {
100 | self.webView.frame = CGRect(origin: CGPoint.zero, size: view.frame.size)
101 | self.webView.alphaValue = 0.01
102 | view.addSubview(self.webView)
103 | } else {
104 | Logger.log(warning)
105 | }
106 | #endif
107 | }
108 | }
109 |
110 | deinit {
111 | dispatch_sync_on_main_thread {
112 | self.webView.removeFromSuperview()
113 | }
114 | }
115 |
116 | //========================================
117 | // MARK: Render Page
118 | //========================================
119 |
120 | internal func renderPageWithRequest(_ request: URLRequest, postAction: PostAction = .none, completionHandler: @escaping RenderCompletion) {
121 | let requestBlock : (_ operation: RenderOperation?) -> Void = { operation in
122 | DispatchQueue.main.async {
123 | if let url = request.url , url.isFileURL {
124 | if #available(OSX 10.11, *) {
125 | _ = operation?.webView?.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
126 | } else {
127 | preconditionFailure("OSX version lower 10.11 not supported.")
128 | }
129 | } else {
130 | _ = operation?.webView?.load(request)
131 | }
132 | }
133 | }
134 | let operation = operationWithRequestBlock(requestBlock, postAction: postAction, completionHandler: completionHandler)
135 | operation.name = "Request".uppercased() + "\n\(request.url?.absoluteString ?? String())"
136 | renderQueue.addOperation(operation)
137 | }
138 |
139 |
140 | //========================================
141 | // MARK: Execute JavaScript
142 | //========================================
143 |
144 | internal func executeScript(_ script: String, willLoadPage: Bool? = false, postAction: PostAction = .none, completionHandler: RenderCompletion?) {
145 | var requestBlock : RequestBlock
146 | if let willLoadPage = willLoadPage , willLoadPage == true {
147 | requestBlock = { operation in
148 | DispatchQueue.main.async {
149 | operation.webView?.evaluateJavaScript(script, completionHandler: nil)
150 | }
151 | }
152 | } else {
153 | requestBlock = { operation in
154 | DispatchQueue.main.async {
155 | operation.webView?.evaluateJavaScript(script, completionHandler: { result, error in
156 | var data : Data?
157 | if let result = result {
158 | data = "\(result)".data(using: String.Encoding.utf8)
159 | }
160 | operation.completeRendering(operation.webView, result: data, error: error)
161 | })
162 | }
163 | }
164 | }
165 | let operation = operationWithRequestBlock(requestBlock, postAction: postAction, completionHandler: completionHandler)
166 | operation.name = "Script".uppercased() + "\n\(script )"
167 | renderQueue.addOperation(operation)
168 | }
169 |
170 | //========================================
171 | // MARK: Helper Methods
172 | //========================================
173 |
174 | fileprivate func operationWithRequestBlock(_ requestBlock: @escaping (_ operation: RenderOperation) -> Void, postAction: PostAction = .none, completionHandler: RenderCompletion?) -> Operation {
175 | let operation = RenderOperation(webView: webView, timeoutInSeconds: timeoutInSeconds)
176 | operation.loadMediaContent = loadMediaContent
177 | operation.showNetworkActivity = showNetworkActivity
178 | operation.postAction = postAction
179 | operation.completionBlock = { [weak operation] in
180 | completionHandler?(operation?.result, operation?.response, operation?.error)
181 | }
182 | operation.requestBlock = requestBlock
183 | operation.authenticationBlock = authenticationHandler
184 | return operation
185 | }
186 |
187 | internal func currentContent(_ completionHandler: @escaping RenderCompletion) {
188 | webView.evaluateJavaScript(Renderer.scrapingCommand.terminate()) { result, error in
189 | var data : Data?
190 | if let result = result {
191 | data = "\(result)".data(using: String.Encoding.utf8)
192 | }
193 | completionHandler(data as AnyObject?, nil, error as Error?)
194 | }
195 | }
196 |
197 | }
198 |
199 | //========================================
200 | // MARK: Cache
201 | //========================================
202 |
203 | extension Renderer {
204 | @available(OSX 10.11, *)
205 | internal func clearCache() {
206 | let distantPast = Date.distantPast
207 | HTTPCookieStorage.shared.removeCookies(since: distantPast)
208 | let websiteDataTypes = Set([WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache])
209 | WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes, modifiedSince: distantPast, completionHandler:{ })
210 | }
211 | }
212 |
213 |
214 | //========================================
215 | // MARK: Snapshot
216 | //========================================
217 |
218 | #if os(iOS)
219 | extension Renderer {
220 | internal func snapshot() -> Snapshot? {
221 | precondition(webView.superview != nil, "WKWebView has no superview. Cannot take snapshot.")
222 | UIGraphicsBeginImageContextWithOptions(webView.bounds.size, true, 0)
223 | webView.scrollView.drawHierarchy(in: webView.bounds, afterScreenUpdates: false)
224 | let snapshot = UIGraphicsGetImageFromCurrentImageContext()
225 | UIGraphicsEndImageContext()
226 |
227 | if let data = UIImagePNGRepresentation(snapshot!) {
228 | return Snapshot(data: data, page: webView.url)
229 | }
230 | return nil
231 | }
232 | }
233 | #endif
234 |
--------------------------------------------------------------------------------
/Sources/WKZombie/Snapshot.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Snapshot.swift
3 | //
4 | // Copyright (c) 2016 Mathias Koehnke (http://www.mathiaskoehnke.de)
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 |
24 | #if os(iOS)
25 | import UIKit
26 | public typealias SnapshotImage = UIImage
27 | #elseif os(OSX)
28 | import Cocoa
29 | public typealias SnapshotImage = NSImage
30 | #endif
31 |
32 |
33 | /// WKZombie Snapshot Helper Class
34 | public class Snapshot {
35 | public let page : URL?
36 | public let file : URL
37 | public lazy var image : SnapshotImage? = {
38 | let path = self.file.path
39 | #if os(iOS)
40 | return UIImage(contentsOfFile: path)
41 | #elseif os(OSX)
42 | return NSImage(contentsOfFile: path)
43 | #endif
44 | }()
45 |
46 | internal init?(data: Data, page: URL? = nil) {
47 | do {
48 | self.file = try Snapshot.store(data)
49 | self.page = page
50 | } catch let error as NSError {
51 | Logger.log("Could not take snapshot: \(error.localizedDescription)")
52 | return nil
53 | }
54 | }
55 |
56 | fileprivate static func store(_ data: Data) throws -> URL {
57 | let identifier = ProcessInfo.processInfo.globallyUniqueString
58 |
59 | let fileName = String(format: "wkzombie-snapshot-%@", identifier)
60 | let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(fileName)
61 |
62 | try data.write(to: fileURL, options: .atomicWrite)
63 |
64 | return fileURL
65 | }
66 |
67 | /**
68 | Moves the snapshot file into the specified directory.
69 |
70 | - parameter directory: A Directory URL.
71 |
72 | - throws: Exception if the moving operation fails.
73 |
74 | - returns: The URL with the new file location.
75 | */
76 | public func moveTo(_ directory: URL) throws -> URL? {
77 | let fileManager = FileManager.default
78 | let fileName = file.lastPathComponent
79 | let destination = directory.appendingPathComponent(fileName)
80 | try fileManager.moveItem(at: file, to: destination)
81 | return destination
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/Tests/WKZombieTests/Tests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Tests.swift
3 | //
4 | // Copyright (c) 2015 Mathias Koehnke (http://www.mathiaskoehnke.de)
5 | //
6 | // Permission is hereby granted, free of charge, to any person obtaining a copy
7 | // of this software and associated documentation files (the "Software"), to deal
8 | // in the Software without restriction, including without limitation the rights
9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | // copies of the Software, and to permit persons to whom the Software is
11 | // furnished to do so, subject to the following conditions:
12 | //
13 | // The above copyright notice and this permission notice shall be included in
14 | // all copies or substantial portions of the Software.
15 | //
16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | // THE SOFTWARE.
23 |
24 | import XCTest
25 | import WKZombie
26 |
27 | class Tests: XCTestCase {
28 |
29 | var browser : WKZombie!
30 |
31 | override func setUp() {
32 | super.setUp()
33 | browser = WKZombie(name: "WKZombie Tests")
34 | }
35 |
36 | override func tearDown() {
37 | super.tearDown()
38 | browser = nil
39 | }
40 |
41 | func testExecute() {
42 | let expectation = self.expectation(description: "JavaScript Done.")
43 |
44 | browser.open(startURL())
45 | >>> browser.execute("document.title")
46 | === { (result: JavaScriptResult?) in
47 | XCTAssertEqual(result, "WKZombie Test Page")
48 | expectation.fulfill()
49 | }
50 |
51 | waitForExpectations(timeout: 20.0, handler: nil)
52 | }
53 |
54 | func testInspect() {
55 | let expectation = self.expectation(description: "Inspect Done.")
56 | var originalPage : HTMLPage?
57 |
58 | browser.open(startURL())
59 | >>> browser.map { originalPage = $0 as HTMLPage }
60 | >>> browser.inspect
61 | === { (result: HTMLPage?) in
62 | if let result = result, let originalPage = originalPage {
63 | XCTAssertEqual(result.data, originalPage.data)
64 | } else {
65 | XCTAssert(false)
66 | }
67 |
68 | expectation.fulfill()
69 | }
70 |
71 | waitForExpectations(timeout: 20.0, handler: nil)
72 | }
73 |
74 | func testButtonPress() {
75 | let expectation = self.expectation(description: "Button Press Done.")
76 |
77 | browser.open(startURL())
78 | >>> browser.get(by: .name("button"))
79 | >>> browser.press
80 | >>> browser.execute("document.title")
81 | === { (result: JavaScriptResult?) in
82 | XCTAssertEqual(result, "WKZombie Result Page")
83 | expectation.fulfill()
84 | }
85 |
86 | waitForExpectations(timeout: 20.0, handler: nil)
87 | }
88 |
89 | func testFormSubmit() {
90 | let expectation = self.expectation(description: "Form Submit Done.")
91 |
92 | browser.open(startURL())
93 | >>> browser.get(by: .id("test_form"))
94 | >>> browser.submit
95 | >>> browser.execute("document.title")
96 | === { (result: JavaScriptResult?) in
97 | XCTAssertEqual(result, "WKZombie Result Page")
98 | expectation.fulfill()
99 | }
100 |
101 | waitForExpectations(timeout: 20.0, handler: nil)
102 | }
103 |
104 | func testFormWithXPathQuerySubmit() {
105 | let expectation = self.expectation(description: "Form XPathQuery Submit Done.")
106 |
107 | browser.open(startURL())
108 | >>> browser.get(by: .XPathQuery("//form[1]"))
109 | >>> browser.submit
110 | >>> browser.execute("document.title")
111 | === { (result: JavaScriptResult?) in
112 | XCTAssertEqual(result, "WKZombie Result Page")
113 | expectation.fulfill()
114 | }
115 |
116 | waitForExpectations(timeout: 20.0, handler: nil)
117 | }
118 |
119 | func testDivOnClick() {
120 | let expectation = self.expectation(description: "DIV OnClick Done.")
121 |
122 | browser.open(startURL())
123 | >>> browser.get(by: .id("onClick_div"))
124 | >>> browser.map { $0.objectForKey("onClick")! }
125 | >>> browser.execute
126 | >>> browser.inspect
127 | >>> browser.execute("document.title")
128 | === { (result: JavaScriptResult?) in
129 | XCTAssertEqual(result, "WKZombie Result Page")
130 | expectation.fulfill()
131 | }
132 |
133 | waitForExpectations(timeout: 20.0, handler: nil)
134 | }
135 |
136 | func testDivHref() {
137 | let expectation = self.expectation(description: "DIV Href Done.")
138 |
139 | browser.open(startURL())
140 | >>> browser.get(by: .id("href_div"))
141 | >>> browser.map { "window.location.href='\($0.objectForKey("href")!)'" }
142 | >>> browser.execute
143 | >>> browser.inspect
144 | >>> browser.execute("document.title")
145 | === { (result: JavaScriptResult?) in
146 | XCTAssertEqual(result, "WKZombie Result Page")
147 | expectation.fulfill()
148 | }
149 |
150 | waitForExpectations(timeout: 20.0, handler: nil)
151 | }
152 |
153 | func testUserAgent() {
154 | let expectation = self.expectation(description: "UserAgent Test Done.")
155 | browser.userAgent = "WKZombie"
156 |
157 | browser.open(startURL())
158 | >>> browser.execute("navigator.userAgent")
159 | === { (result: JavaScriptResult?) in
160 | XCTAssertEqual(result, "WKZombie")
161 | expectation.fulfill()
162 | }
163 |
164 | waitForExpectations(timeout: 20.0, handler: nil)
165 | }
166 |
167 | func testSnapshot() {
168 | let expectation = self.expectation(description: "Snapshot Test Done.")
169 |
170 | var snapshots = [Snapshot]()
171 |
172 | browser.snapshotHandler = { snapshot in
173 | XCTAssertNotNil(snapshot.image)
174 | snapshots.append(snapshot)
175 | }
176 |
177 | browser.open(startURL())
178 | >>> browser.snap
179 | >>> browser.get(by: .name("button"))
180 | >>> browser.press
181 | >>> browser.snap
182 | === { (result: HTMLPage?) in
183 | XCTAssertEqual(snapshots.count, 2)
184 | expectation.fulfill()
185 | }
186 |
187 | waitForExpectations(timeout: 20.0, handler: nil)
188 | }
189 |
190 | func testSwap() {
191 | let expectation = self.expectation(description: "iframe Button Test Done.")
192 |
193 | browser.open(startURL())
194 | >>> browser.get(by: .XPathQuery("//iframe[@name='button_frame']"))
195 | >>> browser.swap
196 | >>> browser.get(by: .XPathQuery("//button[@name='button2']"))
197 | >>> browser.press
198 | >>> browser.execute("document.title")
199 | === { (result: JavaScriptResult?) in
200 | XCTAssertEqual(result, "WKZombie Result Page")
201 | expectation.fulfill()
202 | }
203 |
204 | waitForExpectations(timeout: 20.0, handler: nil)
205 | }
206 |
207 | func testBasicAuthentication() {
208 | let expectation = self.expectation(description: "Basic Authentication Test Done.")
209 |
210 | browser.authenticationHandler = { (challenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) in
211 | return (.useCredential, URLCredential(user: "user", password: "passwd", persistence: .forSession))
212 | }
213 |
214 | let url = URL(string: "https://httpbin.org/basic-auth/user/passwd")!
215 | browser.open(then: .wait(2.0))(url)
216 | >>> browser.get(by: .XPathQuery("//body"))
217 | === { (result: HTMLElement?) in
218 | XCTAssertNotNil(result, "Basic Authentication Test Failed - No Body.")
219 | XCTAssertTrue(result!.hasChildren(), "Basic Authentication Test Failed - No JSON.")
220 | expectation.fulfill()
221 | }
222 |
223 | waitForExpectations(timeout: 20.0, handler: nil)
224 | }
225 |
226 | func testSelfSignedCertificates() {
227 | let expectation = self.expectation(description: "Self Signed Certificate Test Done.")
228 |
229 | browser.authenticationHandler = { (challenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) in
230 | return (.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
231 | }
232 |
233 | let url = URL(string: "https://self-signed.badssl.com")!
234 | browser.open(then: .wait(2.0))(url)
235 | >>> browser.execute("document.title")
236 | === { (result: JavaScriptResult?) in
237 | XCTAssertEqual(result, "self-signed.badssl.com")
238 | expectation.fulfill()
239 | }
240 |
241 | waitForExpectations(timeout: 20.0, handler: nil)
242 | }
243 |
244 | //========================================
245 | // MARK: Helper Methods
246 | //========================================
247 |
248 | private func startURL() -> URL {
249 | let bundle = Bundle(for: type(of: self))
250 | let testPage = bundle.url(forResource: "HTMLTestPage", withExtension: "html")!
251 | return testPage
252 | }
253 | }
254 |
--------------------------------------------------------------------------------
/WKZombie.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 |
3 | s.name = "WKZombie"
4 | s.version = "1.1.1"
5 | s.summary = "WKZombie is a Swift library for iOS/OSX to browse websites without the need of User Interface or API."
6 |
7 | s.description = <<-DESC
8 | WKZombie is a Swift library for iOS/OSX to navigate within websites and collect data without the need of User Interface or API, also known as Headless Browser.
9 | In addition, it can be used to run automated tests, take snapshots or manipulate websites using Javascript.
10 | DESC
11 |
12 | s.homepage = "https://github.com/mkoehnke/WKZombie"
13 |
14 | s.license = { :type => 'MIT', :file => 'LICENSE' }
15 |
16 | s.author = "Mathias Köhnke"
17 |
18 | s.ios.deployment_target = '10.3'
19 | s.osx.deployment_target = '10.12'
20 |
21 | s.source = { :git => "https://github.com/mkoehnke/WKZombie.git", :tag => s.version.to_s }
22 |
23 | s.source_files = "Sources/WKZombie/*.{swift}"
24 | s.exclude_files = "Sources/Exclude"
25 |
26 | s.requires_arc = true
27 |
28 | s.dependency 'hpple', '0.2.0'
29 |
30 | s.pod_target_xcconfig = { 'SWIFT_VERSION' => '4.0' }
31 | end
32 |
--------------------------------------------------------------------------------
/WKZombie.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/WKZombie.xcodeproj/xcshareddata/xcschemes/Tests.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
16 |
18 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
41 |
42 |
43 |
44 |
50 |
51 |
53 |
54 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/WKZombie.xcodeproj/xcshareddata/xcschemes/WKZombie OSX.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
33 |
34 |
40 |
41 |
42 |
43 |
44 |
45 |
56 |
57 |
63 |
64 |
65 |
66 |
67 |
68 |
74 |
75 |
81 |
82 |
83 |
84 |
86 |
87 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/WKZombie.xcodeproj/xcshareddata/xcschemes/WKZombie.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
34 |
40 |
41 |
42 |
43 |
44 |
50 |
51 |
52 |
53 |
54 |
55 |
66 |
67 |
73 |
74 |
75 |
76 |
77 |
78 |
84 |
85 |
91 |
92 |
93 |
94 |
96 |
97 |
100 |
101 |
102 |
--------------------------------------------------------------------------------
/WKZombie.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
|