├── .gitignore ├── TurbolinksDemo ├── server │ ├── lib │ │ ├── turbolinks_demo.rb │ │ └── turbolinks_demo │ │ │ ├── views │ │ │ ├── two.erb │ │ │ ├── protected.erb │ │ │ ├── sign_in.erb │ │ │ ├── slow.erb │ │ │ ├── one.erb │ │ │ ├── index.erb │ │ │ └── layout.erb │ │ │ └── app.rb │ ├── Gemfile │ ├── config.ru │ └── Gemfile.lock ├── ErrorView.swift ├── Error.swift ├── UITests │ ├── Info.plist │ └── UITests.swift ├── NumbersViewController.swift ├── DemoViewController.swift ├── demo-server ├── Info.plist ├── Base.lproj │ ├── Main.storyboard │ └── LaunchScreen.xib ├── AuthenticationController.swift ├── AppDelegate.swift ├── ErrorView.xib └── ApplicationController.swift ├── Turbolinks ├── Action.swift ├── Tests │ ├── Info.plist │ └── ScriptMessageTest.swift ├── Error.swift ├── Info.plist ├── ScriptMessage.swift ├── Visitable.swift ├── VisitableViewController.swift ├── WebView.js ├── VisitableView.swift ├── WebView.swift ├── Visit.swift └── Session.swift ├── Turbolinks.xcodeproj ├── project.xcworkspace │ └── contents.xcworkspacedata ├── xcshareddata │ └── xcschemes │ │ └── Turbolinks.xcscheme └── project.pbxproj ├── TurbolinksDemo.xcodeproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── project.pbxproj ├── turbolinks-ios.xcworkspace └── contents.xcworkspacedata ├── Turbolinks.podspec ├── LICENSE ├── Docs ├── Authentication.md └── QuickStartGuide.md ├── CONDUCT.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata 2 | xcshareddata 3 | 4 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/lib/turbolinks_demo.rb: -------------------------------------------------------------------------------- 1 | require_relative 'turbolinks_demo/app' 2 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/lib/turbolinks_demo/views/two.erb: -------------------------------------------------------------------------------- 1 |

Hello, World!

2 |

Isn’t this fun?

3 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'sinatra' 4 | gem 'sinatra-contrib' 5 | gem 'turbolinks-source' 6 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/config.ru: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path('../lib', __FILE__) 2 | 3 | require 'rubygems' 4 | require 'turbolinks_demo' 5 | 6 | run TurbolinksDemo::App 7 | -------------------------------------------------------------------------------- /Turbolinks/Action.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Action: String { 4 | case Advance = "advance" 5 | case Replace = "replace" 6 | case Restore = "restore" 7 | } 8 | -------------------------------------------------------------------------------- /Turbolinks.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /TurbolinksDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/lib/turbolinks_demo/views/protected.erb: -------------------------------------------------------------------------------- 1 |

Protected Page

2 |

This page requires authorization. You can see it because you’re signed in.

3 |

If you weren’t signed in, the server would have returned a "401 Unauthorized" response.

4 | -------------------------------------------------------------------------------- /turbolinks-ios.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /TurbolinksDemo/ErrorView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class ErrorView: UIView { 4 | @IBOutlet var titleLabel: UILabel! 5 | @IBOutlet var messageLabel: UILabel! 6 | @IBOutlet var retryButton: UIButton! 7 | 8 | var error: Error? { 9 | didSet { 10 | titleLabel.text = error?.title 11 | messageLabel.text = error?.message 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /Turbolinks.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "Turbolinks" 3 | s.version = "3.0.0" 4 | s.summary = "Turbolinks for iOS" 5 | s.homepage = "http://github.com/turbolinks/turbolinks-ios" 6 | s.license = "MIT" 7 | s.authors = { "Sam Stephenson" => "sam@basecamp.com", "Jeffrey Hardy" => "jeff@basecamp.com", "Zach Waugh" => "zach@basecamp.com" } 8 | s.platform = :ios, "8.0" 9 | s.source = { :git => "git@github.com:turbolinks/turbolinks-ios.git", :tag => "v3.0.0" } 10 | s.source_files = "Turbolinks/*.swift" 11 | s.resources = "Turbolinks/*.js" 12 | s.framework = "WebKit" 13 | s.requires_arc = true 14 | end 15 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/lib/turbolinks_demo/views/sign_in.erb: -------------------------------------------------------------------------------- 1 |

Please Sign In

2 |

The page you requested returned a "401 Unauthorized" HTTP error.

3 |

When a request error occurs, Turbolinks calls the SessionDelegate’s session: didFailRequestForVisitable: withError: method.

4 |

The demo app’s ApplicationController implements this method and decides to handle a 401 error by presenting this screen–a separate web view–to request authorization.

5 | 6 |
7 |

Password:

8 |

9 |
10 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/lib/turbolinks_demo/views/slow.erb: -------------------------------------------------------------------------------- 1 |

Slow-loading Page

2 |

This page is rendered with a delay on the server so you can see the loading indicator and test Turbolinks’ preview cache.

3 |

To see the preview cache in action, tap the Back arrow and return to this page. It will load instantly while the slow network request completes in the background.

4 |

Pay attention to the global network activity indicator when you return. Notice that it continues animating while the preview is visible. When it stops, the network request has completed and the preview has been replaced with a fresh copy from the server.

5 |

Tap the Back arrow and use pull-to-refresh before returning to see the slow, uncached version again.

6 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | backports (3.6.8) 5 | multi_json (1.11.2) 6 | rack (1.6.4) 7 | rack-protection (1.5.3) 8 | rack 9 | rack-test (0.6.3) 10 | rack (>= 1.0) 11 | sinatra (1.4.7) 12 | rack (~> 1.5) 13 | rack-protection (~> 1.4) 14 | tilt (>= 1.3, < 3) 15 | sinatra-contrib (1.4.6) 16 | backports (>= 2.0) 17 | multi_json 18 | rack-protection 19 | rack-test 20 | sinatra (~> 1.4.0) 21 | tilt (>= 1.3, < 3) 22 | tilt (2.0.2) 23 | turbolinks-source (5.0.0.beta1.1) 24 | 25 | PLATFORMS 26 | ruby 27 | 28 | DEPENDENCIES 29 | sinatra 30 | sinatra-contrib 31 | turbolinks-source 32 | 33 | BUNDLED WITH 34 | 1.11.2 35 | -------------------------------------------------------------------------------- /TurbolinksDemo/Error.swift: -------------------------------------------------------------------------------- 1 | struct Error { 2 | static let HTTPNotFoundError = Error(title: "Page Not Found", message: "There doesn’t seem to be anything here.") 3 | static let NetworkError = Error(title: "Can’t Connect", message: "TurbolinksDemo can’t connect to the server. Did you remember to start it?\nSee README.md for more instructions.") 4 | static let UnknownError = Error(title: "Unknown Error", message: "An unknown error occurred.") 5 | 6 | let title: String 7 | let message: String 8 | 9 | init(title: String, message: String) { 10 | self.title = title 11 | self.message = message 12 | } 13 | 14 | init(HTTPStatusCode: Int) { 15 | self.title = "Server Error" 16 | self.message = "The server returned an HTTP \(HTTPStatusCode) response." 17 | } 18 | } -------------------------------------------------------------------------------- /Turbolinks/Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /TurbolinksDemo/UITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/lib/turbolinks_demo/views/one.erb: -------------------------------------------------------------------------------- 1 |

How’d You Get Here?

2 |

When you tap the link to this page, Turbolinks calls the SessionDelegate’s session: didProposeVisitToURL: withAction: method.

3 |

The demo app’s ApplicationController implements this method and decides how to handle the URL. In this case, it chooses to present a Turbolinks Visitable.

4 |

See ApplicationController’s presentVisitableForSession: URL: action: method for details on how the Visitable is created and displayed.

5 |

Here’s a link to another page presented in the same manner.

6 | 7 |

or

8 | 9 |

Here's a link to another page that does a replace instead of an advance 10 | -------------------------------------------------------------------------------- /Turbolinks/Error.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public let ErrorDomain = "com.basecamp.Turbolinks" 4 | 5 | public enum ErrorCode: Int { 6 | case httpFailure 7 | case networkFailure 8 | } 9 | 10 | extension NSError { 11 | convenience init(code: ErrorCode, localizedDescription: String) { 12 | self.init(domain: ErrorDomain, code: code.rawValue, userInfo: [NSLocalizedDescriptionKey: localizedDescription]) 13 | } 14 | 15 | convenience init(code: ErrorCode, statusCode: Int) { 16 | self.init(domain: ErrorDomain, code: code.rawValue, userInfo: ["statusCode": statusCode, NSLocalizedDescriptionKey: "HTTP Error: \(statusCode)"]) 17 | } 18 | 19 | convenience init(code: ErrorCode, error: NSError) { 20 | self.init(domain: ErrorDomain, code: code.rawValue, userInfo: ["error": error, NSLocalizedDescriptionKey: error.localizedDescription]) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Turbolinks/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /TurbolinksDemo/NumbersViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | private let CellIdentifier = "CellIdentifier" 4 | 5 | class NumbersViewController: UITableViewController { 6 | override func viewDidLoad() { 7 | super.viewDidLoad() 8 | title = "Numbers" 9 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: CellIdentifier) 10 | } 11 | 12 | override func numberOfSections(in tableView: UITableView) -> Int { 13 | return 1 14 | } 15 | 16 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 17 | return 100 18 | } 19 | 20 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 21 | let cell = tableView.dequeueReusableCell(withIdentifier: CellIdentifier, for: indexPath) 22 | 23 | let number = indexPath.row + 1 24 | cell.textLabel?.text = "Row \(number)" 25 | 26 | return cell 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/lib/turbolinks_demo/views/index.erb: -------------------------------------------------------------------------------- 1 |

Hello from Turbolinks! This demo app will help you get acquainted with the framework.

2 | 3 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright 2016 Basecamp, LLC 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /TurbolinksDemo/DemoViewController.swift: -------------------------------------------------------------------------------- 1 | import Turbolinks 2 | import UIKit 3 | 4 | class DemoViewController: Turbolinks.VisitableViewController { 5 | lazy var errorView: ErrorView = { 6 | let view = Bundle.main.loadNibNamed("ErrorView", owner: self, options: nil)!.first as! ErrorView 7 | view.translatesAutoresizingMaskIntoConstraints = false 8 | view.retryButton.addTarget(self, action: #selector(retry(_:)), for: .touchUpInside) 9 | return view 10 | }() 11 | 12 | func presentError(_ error: Error) { 13 | errorView.error = error 14 | view.addSubview(errorView) 15 | installErrorViewConstraints() 16 | } 17 | 18 | func installErrorViewConstraints() { 19 | view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options: [], metrics: nil, views: [ "view": errorView ])) 20 | view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options: [], metrics: nil, views: [ "view": errorView ])) 21 | } 22 | 23 | @objc func retry(_ sender: AnyObject) { 24 | errorView.removeFromSuperview() 25 | reloadVisitable() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /TurbolinksDemo/demo-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | find-writable-gem-home() { 5 | IFS=":" 6 | local path 7 | local paths=( "$(gem env home)" $(gem env paths) ) 8 | IFS=" " 9 | 10 | for path in "${paths[@]}"; do 11 | if [ -w "$path" ]; then 12 | echo "$path" 13 | return 14 | fi 15 | done 16 | } 17 | 18 | find-bundle-executable() { 19 | gem contents bundler | grep "/exe/bundle$" | head -n 1 20 | } 21 | 22 | warn() { 23 | echo "$1" >&2 24 | } 25 | 26 | error() { 27 | warn "$1" 28 | exit 1 29 | } 30 | 31 | warn "Starting the demo server (press ^C to exit)..." >&2 32 | 33 | export GEM_HOME="$(find-writable-gem-home)" 34 | [ -n "$GEM_HOME" ] || error "Please install Ruby and Bundler first" 35 | 36 | BUNDLE="$(find-bundle-executable)" 37 | if [ ! -x "$BUNDLE" ]; then 38 | warn "Installing Bundler..." 39 | gem install bundler >/dev/null 2>&1 || error "Error installing Bundler" 40 | BUNDLE="$(find-bundle-executable)" 41 | fi 42 | 43 | cd "$(dirname "$0")/server" 44 | 45 | if ! "$BUNDLE" check >/dev/null 2>&1; then 46 | warn "Installing dependencies..." 47 | "$BUNDLE" install >/dev/null 2>&1 || error "Error running \`bundle install\`" 48 | fi 49 | 50 | exec "$BUNDLE" exec rackup 51 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/lib/turbolinks_demo/app.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'sinatra/cookies' 3 | require 'tilt/erb' 4 | require 'turbolinks/source' 5 | 6 | module TurbolinksDemo 7 | class App < Sinatra::Base 8 | helpers Sinatra::Cookies 9 | 10 | get '/' do 11 | @title = 'Demo' 12 | erb :index, layout: :layout 13 | end 14 | 15 | get '/one' do 16 | @title = 'Page One' 17 | erb :one, layout: :layout 18 | end 19 | 20 | get '/two' do 21 | @title = 'Page Two' 22 | erb :two, layout: :layout 23 | end 24 | 25 | get '/slow' do 26 | sleep 2 27 | @title = 'Slow Page' 28 | erb :slow, layout: :layout 29 | end 30 | 31 | get '/protected' do 32 | if cookies[:signed_in] 33 | @title = 'Protected' 34 | erb :protected, layout: :layout 35 | else 36 | throw :halt, [ 401, 'Unauthorized' ] 37 | end 38 | end 39 | 40 | get '/sign-in' do 41 | @title = 'Sign In' 42 | erb :sign_in, layout: :layout 43 | end 44 | 45 | post '/sign-in' do 46 | cookies[:signed_in] = true 47 | redirect to('/') 48 | end 49 | 50 | get '/turbolinks.js' do 51 | send_file(Turbolinks::Source.asset_path + '/turbolinks.js') 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /TurbolinksDemo/server/lib/turbolinks_demo/views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= @title %> 7 | 8 | 16 | 27 | 28 | 29 | <%= yield %> 30 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /TurbolinksDemo/UITests/UITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class TurbolinksDemoUITests: XCTestCase { 4 | let app = XCUIApplication() 5 | 6 | override func setUp() { 7 | super.setUp() 8 | 9 | continueAfterFailure = false 10 | app.launch() 11 | } 12 | 13 | func test_HTTPError() { 14 | app.links["Trigger an HTTP 404"].tap() 15 | XCTAssert(app.staticTexts["Page Not Found"].exists) 16 | XCTAssert(app.buttons["Retry"].exists) 17 | } 18 | 19 | func test_LoggingIn() { 20 | let protectedLink = app.links["Visit a protected area"] 21 | protectedLink.tap() 22 | XCTAssert(app.navigationBars["Sign in"].exists) 23 | 24 | app.buttons["Sign in"].tap() 25 | XCTAssert(app.navigationBars["Protected"].exists) 26 | 27 | app.buttons["Demo"].tap() 28 | protectedLink.tap() 29 | XCTAssert(app.navigationBars["Protected"].exists) 30 | } 31 | 32 | func test_NativeViewController() { 33 | app.links["Load a native view controller"].tap() 34 | XCTAssert(app.navigationBars["Numbers"].exists) 35 | XCTAssert(app.staticTexts["Row 1"].exists) 36 | XCTAssertEqual(app.cells.count, 100) 37 | } 38 | 39 | func test_JavaScriptMessage() { 40 | app.links["Post a message from JavaScript"].tap() 41 | XCTAssert(app.alerts.element.staticTexts["Hello from JavaScript!"].exists) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /TurbolinksDemo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | NSAppTransportSecurity 26 | 27 | NSAllowsArbitraryLoads 28 | 29 | 30 | UILaunchStoryboardName 31 | LaunchScreen 32 | UIMainStoryboardFile 33 | Main 34 | UIRequiredDeviceCapabilities 35 | 36 | armv7 37 | 38 | UISupportedInterfaceOrientations 39 | 40 | UIInterfaceOrientationPortrait 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /TurbolinksDemo/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /TurbolinksDemo/AuthenticationController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebKit 3 | 4 | protocol AuthenticationControllerDelegate: class { 5 | func authenticationControllerDidAuthenticate(_ authenticationController: AuthenticationController) 6 | } 7 | 8 | class AuthenticationController: UIViewController { 9 | var url: URL? 10 | var webViewConfiguration: WKWebViewConfiguration? 11 | weak var delegate: AuthenticationControllerDelegate? 12 | 13 | lazy var webView: WKWebView = { 14 | let configuration = self.webViewConfiguration ?? WKWebViewConfiguration() 15 | let webView = WKWebView(frame: CGRect.zero, configuration: configuration) 16 | webView.translatesAutoresizingMaskIntoConstraints = false 17 | webView.navigationDelegate = self 18 | return webView 19 | }() 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | view.addSubview(webView) 25 | view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options: [], metrics: nil, views: [ "view": webView ])) 26 | view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options: [], metrics: nil, views: [ "view": webView ])) 27 | 28 | if let url = self.url { 29 | webView.load(URLRequest(url: url)) 30 | } 31 | } 32 | } 33 | 34 | extension AuthenticationController: WKNavigationDelegate { 35 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 36 | if let url = navigationAction.request.url, url != self.url { 37 | decisionHandler(.cancel) 38 | delegate?.authenticationControllerDidAuthenticate(self) 39 | return 40 | } 41 | 42 | decisionHandler(.allow) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Turbolinks/Tests/ScriptMessageTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import WebKit 3 | @testable import Turbolinks 4 | 5 | class ScriptMessageTest: XCTestCase { 6 | func testParseWithInvalidBody() { 7 | let script = FakeScriptMessage(body: "foo") 8 | 9 | let message = ScriptMessage.parse(script) 10 | XCTAssertNil(message) 11 | } 12 | 13 | func test_ParseWithInvalidName() { 14 | let script = FakeScriptMessage(body: ["name": "foobar"]) 15 | 16 | let message = ScriptMessage.parse(script) 17 | XCTAssertNil(message) 18 | } 19 | 20 | func test_ParseWithMissingData() { 21 | let script = FakeScriptMessage(body: ["name": "pageLoaded"]) 22 | 23 | let message = ScriptMessage.parse(script) 24 | XCTAssertNil(message) 25 | } 26 | 27 | func test_ParseWithValidBody() { 28 | let data = ["identifier": "123", "restorationIdentifier": "abc", "action": "advance", "location": "http://turbolinks.test"] 29 | let script = FakeScriptMessage(body: ["name": "pageLoaded", "data": data]) 30 | 31 | let message = ScriptMessage.parse(script) 32 | XCTAssertNotNil(message) 33 | XCTAssertEqual(message?.name, ScriptMessageName.PageLoaded) 34 | XCTAssertEqual(message?.identifier, "123") 35 | XCTAssertEqual(message?.restorationIdentifier, "abc") 36 | XCTAssertEqual(message?.action, Action.Advance) 37 | XCTAssertEqual(message?.location, URL(string: "http://turbolinks.test")!) 38 | } 39 | } 40 | 41 | // Can't instantiate a WKScriptMessage directly 42 | private class FakeScriptMessage: WKScriptMessage { 43 | override var body: Any { 44 | return actualBody 45 | } 46 | 47 | var actualBody: Any 48 | 49 | init(body: Any) { 50 | self.actualBody = body 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Turbolinks/ScriptMessage.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | enum ScriptMessageName: String { 4 | case PageLoaded = "pageLoaded" 5 | case ErrorRaised = "errorRaised" 6 | case VisitProposed = "visitProposed" 7 | case VisitStarted = "visitStarted" 8 | case VisitRequestStarted = "visitRequestStarted" 9 | case VisitRequestCompleted = "visitRequestCompleted" 10 | case VisitRequestFailed = "visitRequestFailed" 11 | case VisitRequestFinished = "visitRequestFinished" 12 | case VisitRendered = "visitRendered" 13 | case VisitCompleted = "visitCompleted" 14 | case PageInvalidated = "pageInvalidated" 15 | } 16 | 17 | class ScriptMessage { 18 | let name: ScriptMessageName 19 | let data: [String: AnyObject] 20 | 21 | init(name: ScriptMessageName, data: [String: AnyObject]) { 22 | self.name = name 23 | self.data = data 24 | } 25 | 26 | var identifier: String? { 27 | return data["identifier"] as? String 28 | } 29 | 30 | var restorationIdentifier: String? { 31 | return data["restorationIdentifier"] as? String 32 | } 33 | 34 | var location: URL? { 35 | if let locationString = data["location"] as? String { 36 | return URL(string: locationString) 37 | } 38 | 39 | return nil 40 | } 41 | 42 | var action: Action? { 43 | if let actionString = data["action"] as? String { 44 | return Action(rawValue: actionString) 45 | } 46 | 47 | return nil 48 | } 49 | 50 | static func parse(_ message: WKScriptMessage) -> ScriptMessage? { 51 | guard let body = message.body as? [String: AnyObject], 52 | let rawName = body["name"] as? String, let name = ScriptMessageName(rawValue: rawName), 53 | let data = body["data"] as? [String: AnyObject] else { return nil } 54 | return ScriptMessage(name: name, data: data) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /TurbolinksDemo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebKit 3 | import Turbolinks 4 | 5 | @UIApplicationMain 6 | class AppDelegate: UIResponder, UIApplicationDelegate { 7 | var window: UIWindow? 8 | 9 | // MARK: UIApplicationDelegate 10 | 11 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 12 | return true 13 | } 14 | 15 | func applicationWillResignActive(_ application: UIApplication) { 16 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 17 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. 18 | } 19 | 20 | func applicationDidEnterBackground(_ application: UIApplication) { 21 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 22 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 23 | } 24 | 25 | func applicationWillEnterForeground(_ application: UIApplication) { 26 | // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. 27 | } 28 | 29 | func applicationDidBecomeActive(_ application: UIApplication) { 30 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 31 | } 32 | 33 | func applicationWillTerminate(_ application: UIApplication) { 34 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Turbolinks/Visitable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebKit 3 | 4 | public protocol VisitableDelegate: class { 5 | func visitableViewWillAppear(_ visitable: Visitable) 6 | func visitableViewDidAppear(_ visitable: Visitable) 7 | func visitableDidRequestReload(_ visitable: Visitable) 8 | func visitableDidRequestRefresh(_ visitable: Visitable) 9 | } 10 | 11 | public protocol Visitable: class { 12 | var visitableDelegate: VisitableDelegate? { get set } 13 | var visitableView: VisitableView! { get } 14 | var visitableURL: URL! { get } 15 | func visitableDidRender() 16 | } 17 | 18 | extension Visitable { 19 | public var visitableViewController: UIViewController { 20 | return self as! UIViewController 21 | } 22 | 23 | public func visitableDidRender() { 24 | visitableViewController.title = visitableView.webView?.title 25 | } 26 | 27 | public func reloadVisitable() { 28 | visitableDelegate?.visitableDidRequestReload(self) 29 | } 30 | 31 | func activateVisitableWebView(_ webView: WKWebView) { 32 | visitableView.activateWebView(webView, forVisitable: self) 33 | } 34 | 35 | func deactivateVisitableWebView() { 36 | visitableView.deactivateWebView() 37 | } 38 | 39 | func showVisitableActivityIndicator() { 40 | visitableView.showActivityIndicator() 41 | } 42 | 43 | func hideVisitableActivityIndicator() { 44 | visitableView.hideActivityIndicator() 45 | } 46 | 47 | func updateVisitableScreenshot() { 48 | visitableView.updateScreenshot() 49 | } 50 | 51 | func showVisitableScreenshot() { 52 | visitableView.showScreenshot() 53 | } 54 | 55 | func hideVisitableScreenshot() { 56 | visitableView.hideScreenshot() 57 | } 58 | 59 | func clearVisitableScreenshot() { 60 | visitableView.clearScreenshot() 61 | } 62 | 63 | func visitableWillRefresh() { 64 | visitableView.refreshControl.beginRefreshing() 65 | } 66 | 67 | func visitableDidRefresh() { 68 | visitableView.refreshControl.endRefreshing() 69 | } 70 | 71 | func visitableViewDidRequestRefresh() { 72 | visitableDelegate?.visitableDidRequestRefresh(self) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Docs/Authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | There are a number of ways to handle authentication in a Turbolinks iOS app. Primarily, you will be authenticating the user through a web view and relying on cookies. The framework however doesn't provide any auth-specific handling or APIs, that's completely up to each app, depending on their configuration. 3 | 4 | ## Cookies 5 | If your app does not need to make any authenticated network requests from native code, you can simply use cookies like in a web browser. When your user authenticates, set the appropriate cookies and make sure they are persistent, not session cookies. `WKWebView` will automatically handle persisting those cookies to disk as appropriate between app launches. All your web and XHR requests will then just work. 6 | 7 | 8 | ## Native & Web 9 | If you need to make authenticated network requests from both the web and native code, it's a little more complex, and depends on your particular app. You can authenticate natively and somehow hand those credentials to the web view, or authenticate in the web and send those credentials to native. We don't have any particular recommendations there, but can tell you how we decided to handle it in Basecamp 3 for iOS. 10 | 11 | ### Basecamp 3 12 | For Basecamp 3, we perform authentication completely natively, and get an OAuth token back from our API. We then securely persist this token in the user's Keychain. This OAuth token is used for all network requests by setting a header in `NSURLSession`. This OAuth token is used for all native screens and extensions (share, today, watch). 13 | 14 | When you load a web view for the first time in our app, we get a 401 from Turbolinks. We handle that response by making a special request in a hidden `WKWebView` to an endpoint on our server using our OAuth token which sets the appropriate cookies for the web view. When that request finishes successfully, we know the cookies are set and Turbolinks is ready to go. This only happens the first launch, as the web view cookies will be persisted as mentioned above. 15 | 16 | The key to this strategy is to create a `NSURLRequest` using the OAuth header, and use that to load the web view calling `webView.loadRequest(request)` for the authentication request. If the web view is different from the Turbolinks web view, you'll need to also ensure they're using the same `WKProcessPool` so the cookies are shared. 17 | 18 | 19 | -------------------------------------------------------------------------------- /Turbolinks/VisitableViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | open class VisitableViewController: UIViewController, Visitable { 4 | open weak var visitableDelegate: VisitableDelegate? 5 | 6 | open var visitableURL: URL! 7 | 8 | public convenience init(url: URL) { 9 | self.init() 10 | self.visitableURL = url 11 | } 12 | 13 | // MARK: Visitable View 14 | 15 | open private(set) lazy var visitableView: VisitableView! = { 16 | let view = VisitableView(frame: CGRect.zero) 17 | view.translatesAutoresizingMaskIntoConstraints = false 18 | return view 19 | }() 20 | 21 | fileprivate func installVisitableView() { 22 | view.addSubview(visitableView) 23 | view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options: [], metrics: nil, views: [ "view": visitableView ])) 24 | view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options: [], metrics: nil, views: [ "view": visitableView ])) 25 | } 26 | 27 | // MARK: Visitable 28 | 29 | open func visitableDidRender() { 30 | self.title = visitableView.webView?.title 31 | } 32 | 33 | // MARK: View Lifecycle 34 | 35 | open override func viewDidLoad() { 36 | super.viewDidLoad() 37 | view.backgroundColor = UIColor.white 38 | installVisitableView() 39 | } 40 | 41 | open override func viewWillAppear(_ animated: Bool) { 42 | super.viewWillAppear(animated) 43 | visitableDelegate?.visitableViewWillAppear(self) 44 | } 45 | 46 | open override func viewDidAppear(_ animated: Bool) { 47 | super.viewDidAppear(animated) 48 | visitableDelegate?.visitableViewDidAppear(self) 49 | } 50 | 51 | /* 52 | If the visitableView is a child of the main view, and anchored to its top and bottom, then it's 53 | unlikely you will need to customize the layout. But more complicated view hierarchies and layout 54 | may require explicit control over the contentInset. Below is an example of setting the contentInset 55 | to the layout guides. 56 | 57 | public override func viewDidLayoutSubviews() { 58 | super.viewDidLayoutSubviews() 59 | visitableView.contentInset = UIEdgeInsets(top: topLayoutGuide.length, left: 0, bottom: bottomLayoutGuide.length, right: 0) 60 | } 61 | */ 62 | } 63 | -------------------------------------------------------------------------------- /CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting one of the project maintainers listed below. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Project Maintainers 69 | 70 | * Sam Stephenson <> 71 | * Jeffrey Hardy <> 72 | 73 | ## Attribution 74 | 75 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 76 | available at [http://contributor-covenant.org/version/1/4][version] 77 | 78 | [homepage]: http://contributor-covenant.org 79 | [version]: http://contributor-covenant.org/version/1/4/ 80 | -------------------------------------------------------------------------------- /TurbolinksDemo/Base.lproj/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /TurbolinksDemo/ErrorView.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 | 28 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Turbolinks/WebView.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | function WebView(controller, messageHandler) { 3 | this.controller = controller 4 | this.messageHandler = messageHandler 5 | controller.adapter = this 6 | } 7 | 8 | WebView.prototype = { 9 | pageLoaded: function() { 10 | var restorationIdentifier = this.controller.restorationIdentifier 11 | this.postMessageAfterNextRepaint("pageLoaded", { restorationIdentifier: restorationIdentifier }) 12 | }, 13 | 14 | errorRaised: function(error) { 15 | this.postMessage("errorRaised", { error: error }) 16 | }, 17 | 18 | visitLocationWithActionAndRestorationIdentifier: function(location, action, restorationIdentifier) { 19 | this.controller.startVisitToLocationWithAction(location, action, restorationIdentifier) 20 | }, 21 | 22 | // Current visit 23 | 24 | issueRequestForVisitWithIdentifier: function(identifier) { 25 | if (identifier == this.currentVisit.identifier) { 26 | this.currentVisit.issueRequest() 27 | } 28 | }, 29 | 30 | changeHistoryForVisitWithIdentifier: function(identifier) { 31 | if (identifier == this.currentVisit.identifier) { 32 | this.currentVisit.changeHistory() 33 | } 34 | }, 35 | 36 | loadCachedSnapshotForVisitWithIdentifier: function(identifier) { 37 | if (identifier == this.currentVisit.identifier) { 38 | this.currentVisit.loadCachedSnapshot() 39 | } 40 | }, 41 | 42 | loadResponseForVisitWithIdentifier: function(identifier) { 43 | if (identifier == this.currentVisit.identifier) { 44 | this.currentVisit.loadResponse() 45 | } 46 | }, 47 | 48 | cancelVisitWithIdentifier: function(identifier) { 49 | if (identifier == this.currentVisit.identifier) { 50 | this.currentVisit.cancel() 51 | } 52 | }, 53 | 54 | // Adapter interface 55 | 56 | visitProposedToLocationWithAction: function(location, action) { 57 | this.postMessage("visitProposed", { location: location.absoluteURL, action: action }) 58 | }, 59 | 60 | visitStarted: function(visit) { 61 | this.currentVisit = visit 62 | this.postMessage("visitStarted", { identifier: visit.identifier, hasCachedSnapshot: visit.hasCachedSnapshot() }) 63 | }, 64 | 65 | visitRequestStarted: function(visit) { 66 | this.postMessage("visitRequestStarted", { identifier: visit.identifier }) 67 | }, 68 | 69 | visitRequestCompleted: function(visit) { 70 | this.postMessage("visitRequestCompleted", { identifier: visit.identifier }) 71 | }, 72 | 73 | visitRequestFailedWithStatusCode: function(visit, statusCode) { 74 | this.postMessage("visitRequestFailed", { identifier: visit.identifier, statusCode: statusCode }) 75 | }, 76 | 77 | visitRequestFinished: function(visit) { 78 | this.postMessage("visitRequestFinished", { identifier: visit.identifier }) 79 | }, 80 | 81 | visitRendered: function(visit) { 82 | this.postMessageAfterNextRepaint("visitRendered", { identifier: visit.identifier }) 83 | }, 84 | 85 | visitCompleted: function(visit) { 86 | this.postMessageAfterNextRepaint("visitCompleted", { identifier: visit.identifier, restorationIdentifier: visit.restorationIdentifier }) 87 | }, 88 | 89 | pageInvalidated: function() { 90 | this.postMessage("pageInvalidated") 91 | }, 92 | 93 | // Private 94 | 95 | postMessage: function(name, data) { 96 | this.messageHandler.postMessage({ name: name, data: data || {} }) 97 | }, 98 | 99 | postMessageAfterNextRepaint: function(name, data) { 100 | // Post immediately if document is hidden or message may be queued by call to rAF 101 | if (document.hidden) { 102 | this.postMessage(name, data); 103 | } else { 104 | var postMessage = this.postMessage.bind(this, name, data) 105 | requestAnimationFrame(function() { 106 | requestAnimationFrame(postMessage) 107 | }) 108 | } 109 | } 110 | } 111 | 112 | this.webView = new WebView(Turbolinks.controller, webkit.messageHandlers.turbolinks) 113 | 114 | addEventListener("error", function(event) { 115 | var error = event.message + " (" + event.filename + ":" + event.lineno + ":" + event.colno + ")" 116 | webView.errorRaised(error) 117 | }, false) 118 | 119 | webView.pageLoaded() 120 | })() 121 | -------------------------------------------------------------------------------- /Turbolinks.xcodeproj/xcshareddata/xcschemes/Turbolinks.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 48 | 54 | 55 | 56 | 57 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 80 | 81 | 87 | 88 | 89 | 90 | 91 | 92 | 98 | 99 | 105 | 106 | 107 | 108 | 110 | 111 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /TurbolinksDemo/ApplicationController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebKit 3 | import Turbolinks 4 | 5 | class ApplicationController: UINavigationController { 6 | fileprivate let url = URL(string: "http://localhost:9292")! 7 | fileprivate let webViewProcessPool = WKProcessPool() 8 | 9 | fileprivate var application: UIApplication { 10 | return UIApplication.shared 11 | } 12 | 13 | fileprivate lazy var webViewConfiguration: WKWebViewConfiguration = { 14 | let configuration = WKWebViewConfiguration() 15 | configuration.userContentController.add(self, name: "turbolinksDemo") 16 | configuration.processPool = self.webViewProcessPool 17 | configuration.applicationNameForUserAgent = "TurbolinksDemo" 18 | return configuration 19 | }() 20 | 21 | fileprivate lazy var session: Session = { 22 | let session = Session(webViewConfiguration: self.webViewConfiguration) 23 | session.delegate = self 24 | return session 25 | }() 26 | 27 | override func viewDidLoad() { 28 | super.viewDidLoad() 29 | 30 | // Switching this to false will prevent content from sitting beneath scrollbar 31 | navigationBar.isTranslucent = true 32 | 33 | presentVisitableForSession(session, url: url) 34 | } 35 | 36 | fileprivate func presentVisitableForSession(_ session: Session, url: URL, action: Action = .Advance) { 37 | let visitable = DemoViewController(url: url) 38 | 39 | if action == .Advance { 40 | pushViewController(visitable, animated: true) 41 | } else if action == .Replace { 42 | popViewController(animated: false) 43 | pushViewController(visitable, animated: false) 44 | } 45 | 46 | session.visit(visitable) 47 | } 48 | 49 | fileprivate func presentNumbersViewController() { 50 | let viewController = NumbersViewController() 51 | pushViewController(viewController, animated: true) 52 | } 53 | 54 | fileprivate func presentAuthenticationController() { 55 | let authenticationController = AuthenticationController() 56 | authenticationController.delegate = self 57 | authenticationController.webViewConfiguration = webViewConfiguration 58 | authenticationController.url = url.appendingPathComponent("sign-in") 59 | authenticationController.title = "Sign in" 60 | 61 | let authNavigationController = UINavigationController(rootViewController: authenticationController) 62 | present(authNavigationController, animated: true, completion: nil) 63 | } 64 | } 65 | 66 | extension ApplicationController: SessionDelegate { 67 | func session(_ session: Session, didProposeVisitToURL URL: Foundation.URL, withAction action: Action) { 68 | if URL.path == "/numbers" { 69 | presentNumbersViewController() 70 | } else { 71 | presentVisitableForSession(session, url: URL, action: action) 72 | } 73 | } 74 | 75 | func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, withError error: NSError) { 76 | NSLog("ERROR: %@", error) 77 | guard let demoViewController = visitable as? DemoViewController, let errorCode = ErrorCode(rawValue: error.code) else { return } 78 | 79 | switch errorCode { 80 | case .httpFailure: 81 | let statusCode = error.userInfo["statusCode"] as! Int 82 | switch statusCode { 83 | case 401: 84 | presentAuthenticationController() 85 | case 404: 86 | demoViewController.presentError(.HTTPNotFoundError) 87 | default: 88 | demoViewController.presentError(Error(HTTPStatusCode: statusCode)) 89 | } 90 | case .networkFailure: 91 | demoViewController.presentError(.NetworkError) 92 | } 93 | } 94 | 95 | func sessionDidStartRequest(_ session: Session) { 96 | application.isNetworkActivityIndicatorVisible = true 97 | } 98 | 99 | func sessionDidFinishRequest(_ session: Session) { 100 | application.isNetworkActivityIndicatorVisible = false 101 | } 102 | } 103 | 104 | extension ApplicationController: AuthenticationControllerDelegate { 105 | func authenticationControllerDidAuthenticate(_ authenticationController: AuthenticationController) { 106 | session.reload() 107 | dismiss(animated: true, completion: nil) 108 | } 109 | } 110 | 111 | extension ApplicationController: WKScriptMessageHandler { 112 | func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 113 | if let message = message.body as? String { 114 | let alertController = UIAlertController(title: "Turbolinks", message: message, preferredStyle: .alert) 115 | alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 116 | present(alertController, animated: true, completion: nil) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Docs/QuickStartGuide.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | This guide will walk you through creating a minimal Turbolinks for iOS application. 4 | 5 | We’ll use the demo app’s bundled server in our examples, which runs at `http://localhost:9292/`, but you can adjust the URL and hostname below to point to your own application. See [Running the Demo](../README.md#running-the-demo) for instructions on starting the demo server. 6 | 7 | Note that for the sake of brevity, these examples use a UINavigationController and implement everything inside the AppDelegate. In a real application, you may not want to use a navigation controller, and you should consider factoring these responsibilities out of the AppDelegate and into separate classes. 8 | 9 | ## 1. Create a UINavigationController-based project 10 | 11 | Create a new Xcode project using the Single View Application template. Then, open `AppDelegate.swift` and replace it with the following to create a UINavigationController and make it the window’s root view controller: 12 | 13 | ```swift 14 | import UIKit 15 | 16 | @UIApplicationMain 17 | class AppDelegate: UIResponder, UIApplicationDelegate { 18 | var window: UIWindow? 19 | var navigationController = UINavigationController() 20 | 21 | func applicationDidFinishLaunching(_ application: UIApplication) { 22 | window?.rootViewController = navigationController 23 | } 24 | } 25 | ``` 26 | 27 | ▶️ Build and run the app in the simulator to make sure it works. It won’t do anything interesting, but it should run without error. 28 | 29 | ## 2. Configure your project for Turbolinks 30 | 31 | **Add Turbolinks to your project.** Install the Turbolinks framework using Carthage, CocoaPods, or manually by building `Turbolinks.framework` and linking it to your Xcode project. See [Installation](../README.md#installation) for more instructions. 32 | 33 | **Configure NSAppTransportSecurity for the demo server.** By default, iOS versions 9 and later restrict access to unencrypted HTTP connections. In order for your application to connect to the demo server, you must configure it to allow insecure HTTP requests to `localhost`. 34 | 35 | Run the following command with the path to your application’s `Info.plist` file: 36 | 37 | ``` 38 | plutil -insert NSAppTransportSecurity -json \ 39 | '{"NSExceptionDomains":{"localhost":{"NSExceptionAllowsInsecureHTTPLoads":true}}}' \ 40 | MyApp/Info.plist 41 | ``` 42 | 43 | See [Apple’s property list documentation](https://developer.apple.com/library/prerelease/ios/documentation/General/Reference/InfoPlistKeyReference/Articles/CocoaKeys.html#//apple_ref/doc/uid/TP40009251-SW33) for more information about NSAppTransportSecurity. 44 | 45 | ## 3. Set up a Turbolinks Session and perform an initial visit 46 | 47 | A Turbolinks Session manages a WKWebView instance and moves it between Visitable view controllers when you navigate. Your application is responsible for displaying a Visitable view controller, giving it a URL, and telling the Session to visit it. See [Understanding Turbolinks Concepts](../README.md#understanding-turbolinks-concepts) for details. 48 | 49 | In your AppDelegate, create and retain a Session. Then, create a VisitableViewController with the demo server’s URL, and push it onto the navigation stack. Finally, call `session.visit()` with your view controller to perform the visit. 50 | 51 | ```swift 52 | import UIKit 53 | import Turbolinks 54 | 55 | @UIApplicationMain 56 | class AppDelegate: UIResponder, UIApplicationDelegate { 57 | var window: UIWindow? 58 | var navigationController = UINavigationController() 59 | var session = Session() 60 | 61 | func applicationDidFinishLaunching(_ application: UIApplication) { 62 | window?.rootViewController = navigationController 63 | startApplication() 64 | } 65 | 66 | func startApplication() { 67 | visit(URL: URL(string: "http://localhost:9292")!) 68 | } 69 | 70 | func visit(URL: URL) { 71 | let visitableViewController = VisitableViewController(url: URL) 72 | navigationController.pushViewController(visitableViewController, animated: true) 73 | session.visit(visitableViewController) 74 | } 75 | } 76 | ``` 77 | 78 | ▶️ Ensure the Turbolinks demo server is running and launch the application in the simulator. The demo page should load, but tapping a link will have no effect. 79 | 80 | To handle link taps and initiate a Turbolinks visit, you must configure the Session’s delegate. 81 | 82 | ## 4. Configure the Session’s delegate 83 | 84 | The Session notifies its delegate by proposing a visit whenever you tap a link. It also notifies its delegate when a visit request fails. The Session’s delegate is responsible for handling these events and deciding how to proceed. See [Creating a Session](../README.md#creating-a-session) for details. 85 | 86 | First, assign the Session’s `delegate` property. For demonstration purposes, we’ll make AppDelegate the Session’s delegate. 87 | 88 | ```swift 89 | class AppDelegate: UIResponder, UIApplicationDelegate { 90 | // ... 91 | 92 | func startApplication() { 93 | session.delegate = self 94 | visit(URL: URL(string: "http://localhost:9292")!) 95 | } 96 | } 97 | ``` 98 | 99 | Next, implement the SessionDelegate protocol to handle proposed and failed visits by adding the following class extension just after the last closing brace in the file: 100 | 101 | ```swift 102 | extension AppDelegate: SessionDelegate { 103 | func session(_ session: Session, didProposeVisitToURL URL: URL, withAction action: Action) { 104 | visit(URL: URL) 105 | } 106 | 107 | func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, withError error: NSError) { 108 | let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) 109 | alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 110 | navigationController.present(alert, animated: true, completion: nil) 111 | } 112 | } 113 | ``` 114 | 115 | We handle a proposed visit in the same way as the initial visit: by creating a VisitableViewController, pushing it onto the navigation stack, and visiting it with the Session. When a visit request fails, we display an alert. 116 | 117 | ▶️ Build the app and run it in the simulator. Congratulations! Tapping a link should now work. 118 | 119 | ## 5. Read the documentation 120 | 121 | A real application will want to customize the view controller, respond to different visit actions, and gracefully handle errors. See [Building Your Turbolinks Application](../README.md#building-your-turbolinks-application) for detailed instructions. 122 | -------------------------------------------------------------------------------- /Turbolinks/VisitableView.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | open class VisitableView: UIView { 4 | required public init?(coder aDecoder: NSCoder) { 5 | super.init(coder: aDecoder) 6 | initialize() 7 | } 8 | 9 | public override init(frame: CGRect) { 10 | super.init(frame: frame) 11 | initialize() 12 | } 13 | 14 | private func initialize() { 15 | installHiddenScrollView() 16 | installActivityIndicatorView() 17 | } 18 | 19 | 20 | // MARK: Web View 21 | 22 | open var webView: WKWebView? 23 | public var contentInset: UIEdgeInsets? { 24 | didSet { 25 | updateContentInsets() 26 | } 27 | } 28 | private weak var visitable: Visitable? 29 | 30 | open func activateWebView(_ webView: WKWebView, forVisitable visitable: Visitable) { 31 | self.webView = webView 32 | self.visitable = visitable 33 | addSubview(webView) 34 | addFillConstraints(forView: webView) 35 | installRefreshControl() 36 | showOrHideWebView() 37 | } 38 | 39 | open func deactivateWebView() { 40 | removeRefreshControl() 41 | webView?.removeFromSuperview() 42 | webView = nil 43 | visitable = nil 44 | } 45 | 46 | private func showOrHideWebView() { 47 | webView?.isHidden = isShowingScreenshot 48 | } 49 | 50 | 51 | // MARK: Refresh Control 52 | 53 | open lazy var refreshControl: UIRefreshControl = { 54 | let refreshControl = UIRefreshControl() 55 | refreshControl.addTarget(self, action: #selector(refresh(_:)), for: .valueChanged) 56 | return refreshControl 57 | }() 58 | 59 | open var allowsPullToRefresh: Bool = true { 60 | didSet { 61 | if allowsPullToRefresh { 62 | installRefreshControl() 63 | } else { 64 | removeRefreshControl() 65 | } 66 | } 67 | } 68 | 69 | open var isRefreshing: Bool { 70 | return refreshControl.isRefreshing 71 | } 72 | 73 | private func installRefreshControl() { 74 | if let scrollView = webView?.scrollView , allowsPullToRefresh { 75 | scrollView.addSubview(refreshControl) 76 | } 77 | } 78 | 79 | private func removeRefreshControl() { 80 | refreshControl.endRefreshing() 81 | refreshControl.removeFromSuperview() 82 | } 83 | 84 | @objc func refresh(_ sender: AnyObject) { 85 | visitable?.visitableViewDidRequestRefresh() 86 | } 87 | 88 | 89 | // MARK: Activity Indicator 90 | 91 | open lazy var activityIndicatorView: UIActivityIndicatorView = { 92 | let view = UIActivityIndicatorView(activityIndicatorStyle: .white) 93 | view.translatesAutoresizingMaskIntoConstraints = false 94 | view.color = UIColor.gray 95 | view.hidesWhenStopped = true 96 | return view 97 | }() 98 | 99 | private func installActivityIndicatorView() { 100 | addSubview(activityIndicatorView) 101 | addFillConstraints(forView: activityIndicatorView) 102 | } 103 | 104 | open func showActivityIndicator() { 105 | if !isRefreshing { 106 | activityIndicatorView.startAnimating() 107 | bringSubview(toFront: activityIndicatorView) 108 | } 109 | } 110 | 111 | open func hideActivityIndicator() { 112 | activityIndicatorView.stopAnimating() 113 | } 114 | 115 | 116 | // MARK: Screenshots 117 | 118 | private lazy var screenshotContainerView: UIView = { 119 | let view = UIView(frame: CGRect.zero) 120 | view.translatesAutoresizingMaskIntoConstraints = false 121 | view.backgroundColor = self.backgroundColor 122 | return view 123 | }() 124 | 125 | private var screenshotView: UIView? 126 | 127 | var isShowingScreenshot: Bool { 128 | return screenshotContainerView.superview != nil 129 | } 130 | 131 | open func updateScreenshot() { 132 | guard let webView = self.webView , !isShowingScreenshot, let screenshot = webView.snapshotView(afterScreenUpdates: false) else { return } 133 | 134 | screenshotView?.removeFromSuperview() 135 | screenshot.translatesAutoresizingMaskIntoConstraints = false 136 | screenshotContainerView.addSubview(screenshot) 137 | 138 | screenshotContainerView.addConstraints([ 139 | NSLayoutConstraint(item: screenshot, attribute: .centerX, relatedBy: .equal, toItem: screenshotContainerView, attribute: .centerX, multiplier: 1, constant: 0), 140 | NSLayoutConstraint(item: screenshot, attribute: .top, relatedBy: .equal, toItem: screenshotContainerView, attribute: .top, multiplier: 1, constant: 0), 141 | NSLayoutConstraint(item: screenshot, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: screenshot.bounds.size.width), 142 | NSLayoutConstraint(item: screenshot, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: screenshot.bounds.size.height) 143 | ]) 144 | 145 | screenshotView = screenshot 146 | } 147 | 148 | open func showScreenshot() { 149 | if !isShowingScreenshot && !isRefreshing { 150 | addSubview(screenshotContainerView) 151 | addFillConstraints(forView: screenshotContainerView) 152 | showOrHideWebView() 153 | } 154 | } 155 | 156 | open func hideScreenshot() { 157 | screenshotContainerView.removeFromSuperview() 158 | showOrHideWebView() 159 | } 160 | 161 | open func clearScreenshot() { 162 | screenshotView?.removeFromSuperview() 163 | } 164 | 165 | 166 | // MARK: Hidden Scroll View 167 | 168 | private var hiddenScrollView: UIScrollView = { 169 | let scrollView = UIScrollView(frame: CGRect.zero) 170 | scrollView.translatesAutoresizingMaskIntoConstraints = false 171 | scrollView.scrollsToTop = false 172 | return scrollView 173 | }() 174 | 175 | private func installHiddenScrollView() { 176 | insertSubview(hiddenScrollView, at: 0) 177 | addFillConstraints(forView: hiddenScrollView) 178 | } 179 | 180 | 181 | // MARK: Layout 182 | 183 | override open func layoutSubviews() { 184 | super.layoutSubviews() 185 | updateContentInsets() 186 | } 187 | 188 | private func needsUpdateForContentInsets(_ insets: UIEdgeInsets) -> Bool { 189 | guard let scrollView = webView?.scrollView else { return false } 190 | return scrollView.contentInset.top != insets.top || scrollView.contentInset.bottom != insets.bottom 191 | } 192 | 193 | private func updateWebViewScrollViewInsets(_ insets: UIEdgeInsets) { 194 | guard let scrollView = webView?.scrollView, needsUpdateForContentInsets(insets) && !isRefreshing else { return } 195 | 196 | scrollView.scrollIndicatorInsets = insets 197 | scrollView.contentInset = insets 198 | } 199 | 200 | private func updateContentInsets() { 201 | if #available(iOS 11, *) { 202 | updateWebViewScrollViewInsets(contentInset ?? hiddenScrollView.adjustedContentInset) 203 | } else { 204 | updateWebViewScrollViewInsets(contentInset ?? hiddenScrollView.contentInset) 205 | } 206 | } 207 | 208 | private func addFillConstraints(forView view: UIView) { 209 | addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options: [], metrics: nil, views: [ "view": view ])) 210 | addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options: [], metrics: nil, views: [ "view": view ])) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /Turbolinks/WebView.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | protocol WebViewDelegate: class { 4 | func webView(_ webView: WebView, didProposeVisitToLocation location: URL, withAction action: Action) 5 | func webViewDidInvalidatePage(_ webView: WebView) 6 | func webView(_ webView: WebView, didFailJavaScriptEvaluationWithError error: NSError) 7 | } 8 | 9 | protocol WebViewPageLoadDelegate: class { 10 | func webView(_ webView: WebView, didLoadPageWithRestorationIdentifier restorationIdentifier: String) 11 | } 12 | 13 | protocol WebViewVisitDelegate: class { 14 | func webView(_ webView: WebView, didStartVisitWithIdentifier identifier: String, hasCachedSnapshot: Bool) 15 | func webView(_ webView: WebView, didStartRequestForVisitWithIdentifier identifier: String) 16 | func webView(_ webView: WebView, didCompleteRequestForVisitWithIdentifier identifier: String) 17 | func webView(_ webView: WebView, didFailRequestForVisitWithIdentifier identifier: String, statusCode: Int) 18 | func webView(_ webView: WebView, didFinishRequestForVisitWithIdentifier identifier: String) 19 | func webView(_ webView: WebView, didRenderForVisitWithIdentifier identifier: String) 20 | func webView(_ webView: WebView, didCompleteVisitWithIdentifier identifier: String, restorationIdentifier: String) 21 | } 22 | 23 | class WebView: WKWebView { 24 | weak var delegate: WebViewDelegate? 25 | weak var pageLoadDelegate: WebViewPageLoadDelegate? 26 | weak var visitDelegate: WebViewVisitDelegate? 27 | 28 | init(configuration: WKWebViewConfiguration) { 29 | super.init(frame: CGRect.zero, configuration: configuration) 30 | 31 | let bundle = Bundle(for: type(of: self)) 32 | let source = try! String(contentsOf: bundle.url(forResource: "WebView", withExtension: "js")!, encoding: String.Encoding.utf8) 33 | let userScript = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: true) 34 | configuration.userContentController.addUserScript(userScript) 35 | configuration.userContentController.add(self, name: "turbolinks") 36 | 37 | translatesAutoresizingMaskIntoConstraints = false 38 | scrollView.decelerationRate = UIScrollViewDecelerationRateNormal 39 | 40 | if #available(iOS 11, *) { 41 | scrollView.contentInsetAdjustmentBehavior = .never 42 | } 43 | } 44 | 45 | required init?(coder: NSCoder) { 46 | super.init(coder: coder) 47 | } 48 | 49 | func visitLocation(_ location: URL, withAction action: Action, restorationIdentifier: String?) { 50 | callJavaScriptFunction("webView.visitLocationWithActionAndRestorationIdentifier", withArguments: [location.absoluteString as Optional, action.rawValue as Optional, restorationIdentifier as Optional]) 51 | } 52 | 53 | func issueRequestForVisitWithIdentifier(_ identifier: String) { 54 | callJavaScriptFunction("webView.issueRequestForVisitWithIdentifier", withArguments: [identifier as Optional]) 55 | } 56 | 57 | func changeHistoryForVisitWithIdentifier(_ identifier: String) { 58 | callJavaScriptFunction("webView.changeHistoryForVisitWithIdentifier", withArguments: [identifier as Optional]) 59 | } 60 | 61 | func loadCachedSnapshotForVisitWithIdentifier(_ identifier: String) { 62 | callJavaScriptFunction("webView.loadCachedSnapshotForVisitWithIdentifier", withArguments: [identifier as Optional]) 63 | } 64 | 65 | func loadResponseForVisitWithIdentifier(_ identifier: String) { 66 | callJavaScriptFunction("webView.loadResponseForVisitWithIdentifier", withArguments: [identifier as Optional]) 67 | } 68 | 69 | func cancelVisitWithIdentifier(_ identifier: String) { 70 | callJavaScriptFunction("webView.cancelVisitWithIdentifier", withArguments: [identifier as Optional]) 71 | } 72 | 73 | // MARK: JavaScript Evaluation 74 | 75 | private func callJavaScriptFunction(_ functionExpression: String, withArguments arguments: [AnyObject?] = [], completionHandler: ((AnyObject?) -> ())? = nil) { 76 | guard let script = scriptForCallingJavaScriptFunction(functionExpression, withArguments: arguments) else { 77 | NSLog("Error encoding arguments for JavaScript function `%@'", functionExpression) 78 | return 79 | } 80 | 81 | evaluateJavaScript(script) { (result, error) in 82 | if let result = result as? [String: AnyObject] { 83 | if let error = result["error"] as? String, let stack = result["stack"] as? String { 84 | NSLog("Error evaluating JavaScript function `%@': %@\n%@", functionExpression, error, stack) 85 | } else { 86 | completionHandler?(result["value"]) 87 | } 88 | } else if let error = error { 89 | self.delegate?.webView(self, didFailJavaScriptEvaluationWithError: error as NSError) 90 | } 91 | } 92 | } 93 | 94 | private func scriptForCallingJavaScriptFunction(_ functionExpression: String, withArguments arguments: [AnyObject?]) -> String? { 95 | guard let encodedArguments = encodeJavaScriptArguments(arguments) else { return nil } 96 | 97 | return 98 | "(function(result) {\n" + 99 | " try {\n" + 100 | " result.value = " + functionExpression + "(" + encodedArguments + ")\n" + 101 | " } catch (error) {\n" + 102 | " result.error = error.toString()\n" + 103 | " result.stack = error.stack\n" + 104 | " }\n" + 105 | " return result\n" + 106 | "})({})" 107 | } 108 | 109 | private func encodeJavaScriptArguments(_ arguments: [AnyObject?]) -> String? { 110 | let arguments = arguments.map { $0 == nil ? NSNull() : $0! } 111 | 112 | if let data = try? JSONSerialization.data(withJSONObject: arguments, options: []), 113 | let string = NSString(data: data, encoding: String.Encoding.utf8.rawValue) as String? { 114 | let startIndex = string.index(after: string.startIndex) 115 | let endIndex = string.index(before: string.endIndex) 116 | return String(string[startIndex..") 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Turbolinks/Visit.swift: -------------------------------------------------------------------------------- 1 | import WebKit 2 | 3 | protocol VisitDelegate: class { 4 | func visitDidInitializeWebView(_ visit: Visit) 5 | 6 | func visitWillStart(_ visit: Visit) 7 | func visitDidStart(_ visit: Visit) 8 | func visitDidComplete(_ visit: Visit) 9 | func visitDidFail(_ visit: Visit) 10 | func visitDidFinish(_ visit: Visit) 11 | 12 | func visitWillLoadResponse(_ visit: Visit) 13 | func visitDidRender(_ visit: Visit) 14 | 15 | func visitRequestDidStart(_ visit: Visit) 16 | func visit(_ visit: Visit, requestDidFailWithError error: NSError) 17 | func visitRequestDidFinish(_ visit: Visit) 18 | } 19 | 20 | enum VisitState { 21 | case initialized 22 | case started 23 | case canceled 24 | case failed 25 | case completed 26 | } 27 | 28 | class Visit: NSObject { 29 | weak var delegate: VisitDelegate? 30 | 31 | var visitable: Visitable 32 | var action: Action 33 | var webView: WebView 34 | var state: VisitState 35 | 36 | var location: URL 37 | var hasCachedSnapshot: Bool = false 38 | var restorationIdentifier: String? 39 | 40 | override var description: String { 41 | return "<\(type(of: self)): state=\(state) location=\(location)>" 42 | } 43 | 44 | init(visitable: Visitable, action: Action, webView: WebView) { 45 | self.visitable = visitable 46 | self.location = visitable.visitableURL! as URL 47 | self.action = action 48 | self.webView = webView 49 | self.state = .initialized 50 | } 51 | 52 | func start() { 53 | if state == .initialized { 54 | delegate?.visitWillStart(self) 55 | state = .started 56 | startVisit() 57 | } 58 | } 59 | 60 | func cancel() { 61 | if state == .started { 62 | state = .canceled 63 | cancelVisit() 64 | } 65 | } 66 | 67 | fileprivate func complete() { 68 | if state == .started { 69 | state = .completed 70 | completeVisit() 71 | delegate?.visitDidComplete(self) 72 | delegate?.visitDidFinish(self) 73 | } 74 | } 75 | 76 | fileprivate func fail(_ callback: (() -> Void)? = nil) { 77 | if state == .started { 78 | state = .failed 79 | callback?() 80 | failVisit() 81 | delegate?.visitDidFail(self) 82 | delegate?.visitDidFinish(self) 83 | } 84 | } 85 | 86 | fileprivate func startVisit() {} 87 | fileprivate func cancelVisit() {} 88 | fileprivate func completeVisit() {} 89 | fileprivate func failVisit() {} 90 | 91 | // MARK: Navigation 92 | 93 | fileprivate var navigationCompleted = false 94 | fileprivate var navigationCallback: (() -> Void)? 95 | 96 | func completeNavigation() { 97 | if state == .started && !navigationCompleted { 98 | navigationCompleted = true 99 | navigationCallback?() 100 | } 101 | } 102 | 103 | fileprivate func afterNavigationCompletion(_ callback: @escaping () -> Void) { 104 | if navigationCompleted { 105 | callback() 106 | } else { 107 | let previousNavigationCallback = navigationCallback 108 | navigationCallback = { [unowned self] in 109 | previousNavigationCallback?() 110 | if self.state != .canceled { 111 | callback() 112 | } 113 | } 114 | } 115 | } 116 | 117 | // MARK: Request state 118 | 119 | fileprivate var requestStarted = false 120 | fileprivate var requestFinished = false 121 | 122 | fileprivate func startRequest() { 123 | if !requestStarted { 124 | requestStarted = true 125 | delegate?.visitRequestDidStart(self) 126 | } 127 | } 128 | 129 | fileprivate func finishRequest() { 130 | if requestStarted && !requestFinished { 131 | requestFinished = true 132 | delegate?.visitRequestDidFinish(self) 133 | } 134 | } 135 | } 136 | 137 | class ColdBootVisit: Visit, WKNavigationDelegate, WebViewPageLoadDelegate { 138 | fileprivate var navigation: WKNavigation? 139 | 140 | override fileprivate func startVisit() { 141 | webView.navigationDelegate = self 142 | webView.pageLoadDelegate = self 143 | 144 | let request = URLRequest(url: location) 145 | navigation = webView.load(request) 146 | 147 | delegate?.visitDidStart(self) 148 | startRequest() 149 | } 150 | 151 | override fileprivate func cancelVisit() { 152 | removeNavigationDelegate() 153 | webView.stopLoading() 154 | finishRequest() 155 | } 156 | 157 | override fileprivate func completeVisit() { 158 | removeNavigationDelegate() 159 | delegate?.visitDidInitializeWebView(self) 160 | } 161 | 162 | override fileprivate func failVisit() { 163 | removeNavigationDelegate() 164 | finishRequest() 165 | } 166 | 167 | fileprivate func removeNavigationDelegate() { 168 | if webView.navigationDelegate === self { 169 | webView.navigationDelegate = nil 170 | } 171 | } 172 | 173 | // MARK: WKNavigationDelegate 174 | 175 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 176 | if navigation === self.navigation { 177 | finishRequest() 178 | } 179 | } 180 | 181 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 182 | // Ignore any clicked links before the cold boot finishes navigation 183 | if navigationAction.navigationType == .linkActivated { 184 | decisionHandler(.cancel) 185 | if let URL = navigationAction.request.url { 186 | UIApplication.shared.openURL(URL) 187 | } 188 | } else { 189 | decisionHandler(.allow) 190 | } 191 | } 192 | 193 | func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { 194 | if let httpResponse = navigationResponse.response as? HTTPURLResponse { 195 | if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { 196 | decisionHandler(.allow) 197 | } else { 198 | decisionHandler(.cancel) 199 | fail { 200 | let error = NSError(code: .httpFailure, statusCode: httpResponse.statusCode) 201 | self.delegate?.visit(self, requestDidFailWithError: error) 202 | } 203 | } 204 | } else { 205 | decisionHandler(.cancel) 206 | fail { 207 | let error = NSError(code: .networkFailure, localizedDescription: "An unknown error occurred") 208 | self.delegate?.visit(self, requestDidFailWithError: error) 209 | } 210 | } 211 | } 212 | 213 | func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { 214 | if navigation === self.navigation { 215 | fail { 216 | let error = NSError(code: .networkFailure, error: error as NSError) 217 | self.delegate?.visit(self, requestDidFailWithError: error) 218 | } 219 | } 220 | } 221 | 222 | func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 223 | if navigation === self.navigation { 224 | fail { 225 | let error = NSError(code: .networkFailure, error: error as NSError) 226 | self.delegate?.visit(self, requestDidFailWithError: error) 227 | } 228 | } 229 | } 230 | 231 | // MARK: WebViewPageLoadDelegate 232 | 233 | func webView(_ webView: WebView, didLoadPageWithRestorationIdentifier restorationIdentifier: String) { 234 | self.restorationIdentifier = restorationIdentifier 235 | delegate?.visitDidRender(self) 236 | complete() 237 | } 238 | } 239 | 240 | class JavaScriptVisit: Visit, WebViewVisitDelegate { 241 | fileprivate var identifier = "(pending)" 242 | 243 | override var description: String { 244 | return "<\(type(of: self)) \(identifier): state=\(state) location=\(location)>" 245 | } 246 | 247 | override fileprivate func startVisit() { 248 | webView.visitDelegate = self 249 | webView.visitLocation(location, withAction: action, restorationIdentifier: restorationIdentifier) 250 | } 251 | 252 | override fileprivate func cancelVisit() { 253 | webView.cancelVisitWithIdentifier(identifier) 254 | finishRequest() 255 | } 256 | 257 | override fileprivate func failVisit() { 258 | finishRequest() 259 | } 260 | 261 | // MARK: WebViewVisitDelegate 262 | 263 | func webView(_ webView: WebView, didStartVisitWithIdentifier identifier: String, hasCachedSnapshot: Bool) { 264 | self.identifier = identifier 265 | self.hasCachedSnapshot = hasCachedSnapshot 266 | 267 | delegate?.visitDidStart(self) 268 | webView.issueRequestForVisitWithIdentifier(identifier) 269 | 270 | afterNavigationCompletion { [unowned self] in 271 | self.webView.changeHistoryForVisitWithIdentifier(identifier) 272 | self.webView.loadCachedSnapshotForVisitWithIdentifier(identifier) 273 | } 274 | } 275 | 276 | func webView(_ webView: WebView, didStartRequestForVisitWithIdentifier identifier: String) { 277 | if identifier == self.identifier { 278 | startRequest() 279 | } 280 | } 281 | 282 | func webView(_ webView: WebView, didCompleteRequestForVisitWithIdentifier identifier: String) { 283 | if identifier == self.identifier { 284 | afterNavigationCompletion { [unowned self] in 285 | self.delegate?.visitWillLoadResponse(self) 286 | self.webView.loadResponseForVisitWithIdentifier(identifier) 287 | } 288 | } 289 | } 290 | 291 | func webView(_ webView: WebView, didFailRequestForVisitWithIdentifier identifier: String, statusCode: Int) { 292 | if identifier == self.identifier { 293 | fail { 294 | let error: NSError 295 | if statusCode == 0 { 296 | error = NSError(code: .networkFailure, localizedDescription: "A network error occurred.") 297 | } else { 298 | error = NSError(code: .httpFailure, statusCode: statusCode) 299 | } 300 | self.delegate?.visit(self, requestDidFailWithError: error) 301 | } 302 | } 303 | } 304 | 305 | func webView(_ webView: WebView, didFinishRequestForVisitWithIdentifier identifier: String) { 306 | if identifier == self.identifier { 307 | finishRequest() 308 | } 309 | } 310 | 311 | func webView(_ webView: WebView, didRenderForVisitWithIdentifier identifier: String) { 312 | if identifier == self.identifier { 313 | delegate?.visitDidRender(self) 314 | } 315 | } 316 | 317 | func webView(_ webView: WebView, didCompleteVisitWithIdentifier identifier: String, restorationIdentifier: String) { 318 | if identifier == self.identifier { 319 | self.restorationIdentifier = restorationIdentifier 320 | complete() 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /Turbolinks/Session.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import WebKit 3 | 4 | public protocol SessionDelegate: class { 5 | func session(_ session: Session, didProposeVisitToURL URL: URL, withAction action: Action) 6 | func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, withError error: NSError) 7 | func session(_ session: Session, openExternalURL URL: URL) 8 | func sessionDidLoadWebView(_ session: Session) 9 | func sessionDidStartRequest(_ session: Session) 10 | func sessionDidFinishRequest(_ session: Session) 11 | } 12 | 13 | public extension SessionDelegate { 14 | func sessionDidLoadWebView(_ session: Session) { 15 | session.webView.navigationDelegate = session 16 | } 17 | 18 | func session(_ session: Session, openExternalURL URL: Foundation.URL) { 19 | UIApplication.shared.openURL(URL) 20 | } 21 | 22 | func sessionDidStartRequest(_ session: Session) { 23 | } 24 | 25 | func sessionDidFinishRequest(_ session: Session) { 26 | } 27 | } 28 | 29 | open class Session: NSObject { 30 | open weak var delegate: SessionDelegate? 31 | 32 | open var webView: WKWebView { 33 | return _webView 34 | } 35 | 36 | fileprivate var _webView: WebView 37 | fileprivate var initialized = false 38 | fileprivate var refreshing = false 39 | 40 | public init(webViewConfiguration: WKWebViewConfiguration) { 41 | _webView = WebView(configuration: webViewConfiguration) 42 | super.init() 43 | _webView.delegate = self 44 | } 45 | 46 | public convenience override init() { 47 | self.init(webViewConfiguration: WKWebViewConfiguration()) 48 | } 49 | 50 | // MARK: Visiting 51 | 52 | fileprivate var currentVisit: Visit? 53 | fileprivate var topmostVisit: Visit? 54 | 55 | open var topmostVisitable: Visitable? { 56 | return topmostVisit?.visitable 57 | } 58 | 59 | open func visit(_ visitable: Visitable) { 60 | visitVisitable(visitable, action: .Advance) 61 | } 62 | 63 | fileprivate func visitVisitable(_ visitable: Visitable, action: Action) { 64 | guard visitable.visitableURL != nil else { return } 65 | 66 | visitable.visitableDelegate = self 67 | 68 | let visit: Visit 69 | 70 | if initialized { 71 | visit = JavaScriptVisit(visitable: visitable, action: action, webView: _webView) 72 | visit.restorationIdentifier = restorationIdentifierForVisitable(visitable) 73 | } else { 74 | visit = ColdBootVisit(visitable: visitable, action: action, webView: _webView) 75 | } 76 | 77 | currentVisit?.cancel() 78 | currentVisit = visit 79 | 80 | visit.delegate = self 81 | visit.start() 82 | } 83 | 84 | open func reload() { 85 | if let visitable = topmostVisitable { 86 | initialized = false 87 | visit(visitable) 88 | topmostVisit = currentVisit 89 | } 90 | } 91 | 92 | // MARK: Visitable activation 93 | 94 | fileprivate var activatedVisitable: Visitable? 95 | 96 | fileprivate func activateVisitable(_ visitable: Visitable) { 97 | if visitable !== activatedVisitable { 98 | if let activatedVisitable = self.activatedVisitable { 99 | deactivateVisitable(activatedVisitable, showScreenshot: true) 100 | } 101 | 102 | visitable.activateVisitableWebView(webView) 103 | activatedVisitable = visitable 104 | } 105 | } 106 | 107 | fileprivate func deactivateVisitable(_ visitable: Visitable, showScreenshot: Bool = false) { 108 | if visitable === activatedVisitable { 109 | if showScreenshot { 110 | visitable.updateVisitableScreenshot() 111 | visitable.showVisitableScreenshot() 112 | } 113 | 114 | visitable.deactivateVisitableWebView() 115 | activatedVisitable = nil 116 | } 117 | } 118 | 119 | // MARK: Visitable restoration identifiers 120 | 121 | fileprivate var visitableRestorationIdentifiers = NSMapTable(keyOptions: NSPointerFunctions.Options.weakMemory, valueOptions: []) 122 | 123 | fileprivate func restorationIdentifierForVisitable(_ visitable: Visitable) -> String? { 124 | return visitableRestorationIdentifiers.object(forKey: visitable.visitableViewController) as String? 125 | } 126 | 127 | fileprivate func storeRestorationIdentifier(_ restorationIdentifier: String, forVisitable visitable: Visitable) { 128 | visitableRestorationIdentifiers.setObject(restorationIdentifier as NSString, forKey: visitable.visitableViewController) 129 | } 130 | 131 | fileprivate func completeNavigationForCurrentVisit() { 132 | if let visit = currentVisit { 133 | topmostVisit = visit 134 | visit.completeNavigation() 135 | } 136 | } 137 | } 138 | 139 | extension Session: VisitDelegate { 140 | func visitRequestDidStart(_ visit: Visit) { 141 | delegate?.sessionDidStartRequest(self) 142 | } 143 | 144 | func visitRequestDidFinish(_ visit: Visit) { 145 | delegate?.sessionDidFinishRequest(self) 146 | } 147 | 148 | func visit(_ visit: Visit, requestDidFailWithError error: NSError) { 149 | delegate?.session(self, didFailRequestForVisitable: visit.visitable, withError: error) 150 | } 151 | 152 | func visitDidInitializeWebView(_ visit: Visit) { 153 | initialized = true 154 | delegate?.sessionDidLoadWebView(self) 155 | } 156 | 157 | func visitWillStart(_ visit: Visit) { 158 | visit.visitable.showVisitableScreenshot() 159 | activateVisitable(visit.visitable) 160 | } 161 | 162 | func visitDidStart(_ visit: Visit) { 163 | if !visit.hasCachedSnapshot { 164 | visit.visitable.showVisitableActivityIndicator() 165 | } 166 | } 167 | 168 | func visitWillLoadResponse(_ visit: Visit) { 169 | visit.visitable.updateVisitableScreenshot() 170 | visit.visitable.showVisitableScreenshot() 171 | } 172 | 173 | func visitDidRender(_ visit: Visit) { 174 | visit.visitable.hideVisitableScreenshot() 175 | visit.visitable.hideVisitableActivityIndicator() 176 | visit.visitable.visitableDidRender() 177 | } 178 | 179 | func visitDidComplete(_ visit: Visit) { 180 | if let restorationIdentifier = visit.restorationIdentifier { 181 | storeRestorationIdentifier(restorationIdentifier, forVisitable: visit.visitable) 182 | } 183 | } 184 | 185 | func visitDidFail(_ visit: Visit) { 186 | visit.visitable.clearVisitableScreenshot() 187 | visit.visitable.showVisitableScreenshot() 188 | } 189 | 190 | func visitDidFinish(_ visit: Visit) { 191 | if refreshing { 192 | refreshing = false 193 | visit.visitable.visitableDidRefresh() 194 | } 195 | } 196 | } 197 | 198 | extension Session: VisitableDelegate { 199 | public func visitableViewWillAppear(_ visitable: Visitable) { 200 | guard let topmostVisit = self.topmostVisit, let currentVisit = self.currentVisit else { return } 201 | 202 | if visitable === topmostVisit.visitable && visitable.visitableViewController.isMovingToParentViewController { 203 | // Back swipe gesture canceled 204 | if topmostVisit.state == .completed { 205 | currentVisit.cancel() 206 | } else { 207 | visitVisitable(visitable, action: .Advance) 208 | } 209 | } else if visitable === currentVisit.visitable && currentVisit.state == .started { 210 | // Navigating forward - complete navigation early 211 | completeNavigationForCurrentVisit() 212 | } else if visitable !== topmostVisit.visitable { 213 | // Navigating backward 214 | visitVisitable(visitable, action: .Restore) 215 | } 216 | } 217 | 218 | public func visitableViewDidAppear(_ visitable: Visitable) { 219 | if let currentVisit = self.currentVisit , visitable === currentVisit.visitable { 220 | // Appearing after successful navigation 221 | completeNavigationForCurrentVisit() 222 | if currentVisit.state != .failed { 223 | activateVisitable(visitable) 224 | } 225 | } else if let topmostVisit = self.topmostVisit , visitable === topmostVisit.visitable && topmostVisit.state == .completed { 226 | // Reappearing after canceled navigation 227 | visitable.hideVisitableScreenshot() 228 | visitable.hideVisitableActivityIndicator() 229 | activateVisitable(visitable) 230 | } 231 | } 232 | 233 | public func visitableDidRequestReload(_ visitable: Visitable) { 234 | if visitable === topmostVisitable { 235 | reload() 236 | } 237 | } 238 | 239 | public func visitableDidRequestRefresh(_ visitable: Visitable) { 240 | if visitable === topmostVisitable { 241 | refreshing = true 242 | visitable.visitableWillRefresh() 243 | reload() 244 | } 245 | } 246 | } 247 | 248 | extension Session: WebViewDelegate { 249 | func webView(_ webView: WebView, didProposeVisitToLocation location: URL, withAction action: Action) { 250 | delegate?.session(self, didProposeVisitToURL: location, withAction: action) 251 | } 252 | 253 | func webViewDidInvalidatePage(_ webView: WebView) { 254 | if let visitable = topmostVisitable { 255 | visitable.updateVisitableScreenshot() 256 | visitable.showVisitableScreenshot() 257 | visitable.showVisitableActivityIndicator() 258 | reload() 259 | } 260 | } 261 | 262 | func webView(_ webView: WebView, didFailJavaScriptEvaluationWithError error: NSError) { 263 | if let currentVisit = self.currentVisit , initialized { 264 | initialized = false 265 | currentVisit.cancel() 266 | visit(currentVisit.visitable) 267 | } 268 | } 269 | } 270 | 271 | extension Session: WKNavigationDelegate { 272 | public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> ()) { 273 | let navigationDecision = NavigationDecision(navigationAction: navigationAction) 274 | decisionHandler(navigationDecision.policy) 275 | 276 | if let URL = navigationDecision.externallyOpenableURL { 277 | openExternalURL(URL) 278 | } else if navigationDecision.shouldReloadPage { 279 | reload() 280 | } 281 | } 282 | 283 | fileprivate struct NavigationDecision { 284 | let navigationAction: WKNavigationAction 285 | 286 | var policy: WKNavigationActionPolicy { 287 | return navigationAction.navigationType == .linkActivated || isMainFrameNavigation ? .cancel : .allow 288 | } 289 | 290 | var externallyOpenableURL: URL? { 291 | if let URL = navigationAction.request.url , shouldOpenURLExternally { 292 | return URL 293 | } else { 294 | return nil 295 | } 296 | } 297 | 298 | var shouldOpenURLExternally: Bool { 299 | let type = navigationAction.navigationType 300 | return type == .linkActivated || (isMainFrameNavigation && type == .other) 301 | } 302 | 303 | var shouldReloadPage: Bool { 304 | let type = navigationAction.navigationType 305 | return isMainFrameNavigation && type == .reload 306 | } 307 | 308 | var isMainFrameNavigation: Bool { 309 | return navigationAction.targetFrame?.isMainFrame ?? false 310 | } 311 | } 312 | 313 | fileprivate func openExternalURL(_ URL: Foundation.URL) { 314 | delegate?.session(self, openExternalURL: URL) 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turbolinks for iOS 2 | 3 | **Build high-fidelity hybrid apps with native navigation and a single shared web view.** Turbolinks for iOS provides the tooling to wrap your [Turbolinks 5](https://github.com/turbolinks/turbolinks)-enabled web app in a native iOS shell. It manages a single WKWebView instance across multiple view controllers, giving you native navigation UI with all the client-side performance benefits of Turbolinks. 4 | 5 | ## Features 6 | 7 | - **Deliver fast, efficient hybrid apps.** Avoid reloading JavaScript and CSS. Save memory by sharing one WKWebView. 8 | - **Reuse mobile web views across platforms.** Create your views once, on the server, in HTML. Deploy them to iOS, [Android](https://github.com/turbolinks/turbolinks-android), and mobile browsers simultaneously. Ship new features without waiting on App Store approval. 9 | - **Enhance web views with native UI.** Navigate web views using native patterns. Augment web UI with native controls. 10 | - **Produce large apps with small teams.** Achieve baseline HTML coverage for free. Upgrade to native views as needed. 11 | 12 | ## Requirements 13 | 14 | Turbolinks for iOS is written in Swift 4.0 and requires Xcode 9. It should also work with Swift 3.2 as well. It currently supports iOS 8 or higher, but we'll most likely drop iOS 8 support soon. 15 | 16 | Web views are backed by [WKWebView](https://developer.apple.com/library/ios/documentation/WebKit/Reference/WKWebView_Ref/) for full-speed JavaScript performance. 17 | 18 | **Note:** You should understand how Turbolinks works with web applications in the browser before attempting to use Turbolinks for iOS. See the [Turbolinks 5 documentation](https://github.com/turbolinks/turbolinks) for details. 19 | 20 | ## Installation 21 | 22 | Install Turbolinks manually by building `Turbolinks.framework` and linking it to your project. 23 | 24 | ### Installing with Carthage 25 | 26 | Add the following to your `Cartfile`: 27 | 28 | ``` 29 | github "turbolinks/turbolinks-ios" "master" 30 | ``` 31 | 32 | ### Installing with CocoaPods 33 | 34 | Add the following to your `Podfile`: 35 | 36 | ```ruby 37 | use_frameworks! 38 | pod 'Turbolinks', :git => 'https://github.com/turbolinks/turbolinks-ios.git' 39 | ``` 40 | 41 | Then run `pod install`. 42 | 43 | ## Running the Demo 44 | 45 | This repository includes a demo application to show off features of the framework. The demo bundles a simple HTTP server that serves a Turbolinks 5 web app on localhost at port 9292. 46 | 47 | To run the demo, clone this repository to your computer and change into its directory. Then, start the demo server by running `TurbolinksDemo/demo-server` from the command line. 48 | 49 | Once you’ve started the demo server, explore the demo application in the Simulator by opening `turbolinks-ios.xcworkspace` and running the TurbolinksDemo target. 50 | 51 | ![Turbolinks for iOS demo application](https://s3.amazonaws.com/turbolinks-docs/images/ios-demo.png) 52 | 53 | ## Getting Started 54 | 55 | We recommend playing with the demo app to get familiar with the framework. When you’re ready to start your own application, see our [Quick Start Guide](Docs/QuickStartGuide.md) for step-by-step instructions to lay the foundation. 56 | 57 | # Understanding Turbolinks Concepts 58 | 59 | The Session class is the central coordinator in a Turbolinks for iOS application. It creates and manages a single WKWebView instance, and lets its delegate—your application—choose how to handle link taps, present view controllers, and deal with network errors. 60 | 61 | A Visitable is a UIViewController that can be visited by the Session. Each Visitable view controller provides a VisitableView instance, which acts as a container for the Session’s shared WKWebView. The VisitableView has a pull-to-refresh control and an activity indicator. It also displays a screenshot of its contents when the web view moves to another VisitableView. 62 | 63 | When you tap a Turbolinks-enabled link in the web view, the Session asks your application how to handle the link’s URL. Most of the time, your application will visit the URL by creating and presenting a Visitable. But it might also choose to present a native view controller for the URL, or to ignore the URL entirely. 64 | 65 | ## Creating a Session 66 | 67 | To create a Session, first create a [WKWebViewConfiguration](https://developer.apple.com/library/ios/documentation/WebKit/Reference/WKWebViewConfiguration_Ref/index.html) and configure it as needed (see [Customizing the Web View Configuration](#customizing-the-web-view-configuration) for details). Then pass this configuration to the Session initializer and set the `delegate` property on the returned instance. 68 | 69 | The Session’s delegate must implement the following two methods. 70 | 71 | ```swift 72 | func session(session: Session, didProposeVisitToURL URL: NSURL, withAction action: Action) 73 | ``` 74 | 75 | Turbolinks for iOS calls the `session:didProposeVisitToURL:withAction:` method before every [application visit](https://github.com/turbolinks/turbolinks/blob/master/README.md#application-visits), such as when you tap a Turbolinks-enabled link or call `Turbolinks.visit(...)` in your web application. Implement this method to choose how to handle the specified URL and action. 76 | 77 | See [Responding to Visit Proposals](#responding-to-visit-proposals) for more details. 78 | 79 | ```swift 80 | func session(session: Session, didFailRequestForVisitable visitable: Visitable, withError error: NSError) 81 | ``` 82 | 83 | Turbolinks calls `session:didFailRequestForVisitable:withError:` when a visit’s network request fails. Use this method to respond to the error by displaying an appropriate message, or by requesting authentication credentials in the case of an authorization failure. 84 | 85 | See [Handling Failed Requests](#handling-failed-requests) for more details. 86 | 87 | ## Working with Visitables 88 | 89 | Visitable view controllers must conform to the Visitable protocol by implementing the following three properties: 90 | 91 | ```swift 92 | protocol Visitable { 93 | weak var visitableDelegate: VisitableDelegate? { get set } 94 | var visitableView: VisitableView! { get } 95 | var visitableURL: NSURL! { get } 96 | } 97 | ``` 98 | 99 | Turbolinks for iOS provides a VisitableViewController class that implements the Visitable protocol for you. This view controller displays the VisitableView as its single subview. 100 | 101 | Most applications will want to subclass VisitableViewController to customize its layout or add additional views. For example, the bundled demo application has a [DemoViewController subclass](TurbolinksDemo/DemoViewController.swift) that can display a custom error view in place of the VisitableView. 102 | 103 | If your application’s design prevents you from subclassing VisitableViewController, you can implement the Visitable protocol yourself. See the [VisitableViewController implementation](Turbolinks/VisitableViewController.swift) for details. 104 | 105 | Note that custom Visitable view controllers must forward their `viewWillAppear` and `viewDidAppear` methods to the Visitable delegate’s `visitableViewWillAppear` and `visitableViewDidAppear` methods. The Session uses these hooks to know when it should move the WKWebView from one VisitableView to another. 106 | 107 | 108 | # Building Your Turbolinks Application 109 | 110 | ## Initiating a Visit 111 | 112 | To visit a URL with Turbolinks, first instantiate a Visitable view controller. Then present the view controller and pass it to the Session’s `visit` method. 113 | 114 | For example, to create, display, and visit Turbolinks’ built-in VisitableViewController in a UINavigationController-based application, you might write: 115 | 116 | ```swift 117 | let visitable = VisitableViewController() 118 | visitable.URL = NSURL(string: "http://localhost:9292/")! 119 | 120 | navigationController.pushViewController(visitable, animated: true) 121 | session.visit(visitable) 122 | ``` 123 | 124 | ## Responding to Visit Proposals 125 | 126 | When you tap a Turbolinks-enabled link, the link’s URL and action make their way from the web view to the Session as a proposed visit. Your Session’s delegate must implement the `session:didProposeVisitToURL:withAction:` method to choose how to act on each proposal. 127 | 128 | Normally you’ll respond to a visit proposal by simply initiating a visit and loading the URL with Turbolinks. See [Initiating a Visit](#initiating-a-visit) for more details. 129 | 130 | You can also choose to intercept the proposed visit and display a native view controller instead. This lets you transparently upgrade pages to native views on a per-URL basis. See the demo application for an example. 131 | 132 | ### Implementing Visit Actions 133 | 134 | Each proposed visit has an Action, which tells you how you should present the Visitable. 135 | 136 | The default Action is `.Advance`. In most cases you’ll respond to an advance visit by pushing a Visitable view controller for the URL onto the navigation stack. 137 | 138 | When you follow a link annotated with `data-turbolinks-action="replace"`, the proposed Action will be `.Replace`. Usually you’ll want to handle a replace visit by popping the topmost view controller from the navigation stack and pushing a new Visitable for the proposed URL without animation. 139 | 140 | ## Handling Form Submission 141 | 142 | By default, Turbolinks for iOS prevents standard HTML form submissions. This is because a form submission often results in redirection to a different URL, which means the Visitable view controller’s URL would change in place. 143 | 144 | Instead, we recommend submitting forms with JavaScript using XMLHttpRequest, and using the response to tell Turbolinks where to navigate afterwards. See [Redirecting After a Form Submission](https://github.com/turbolinks/turbolinks#redirecting-after-a-form-submission) in the Turbolinks documentation for more details. 145 | 146 | ## Handling Failed Requests 147 | 148 | Turbolinks for iOS calls the `session:didFailRequestForVisitable:withError:` method when a visit request fails. This might be because of a network error, or because the server returned an HTTP 4xx or 5xx status code. 149 | 150 | The NSError object provides details about the error. Access its `code` property to see why the request failed. 151 | 152 | An error code of `.HTTPFailure` indicates that the server returned an HTTP error. You can access the HTTP status code in the error object's `userInfo` dictionary under the key `"statusCode"`. 153 | 154 | An error code of `.NetworkFailure` indicates a problem with the network connection: the connection may be offline, the server may be unavailable, or the request may have timed out without receiving a response. 155 | 156 | ```swift 157 | func session(session: Session, didFailRequestForVisitable visitable: Visitable, withError error: NSError) { 158 | guard let errorCode = ErrorCode(rawValue: error.code) else { return } 159 | 160 | switch errorCode { 161 | case .HTTPFailure: 162 | let statusCode = error.userInfo["statusCode"] as! Int 163 | // Display or handle the HTTP error code 164 | case .NetworkFailure: 165 | // Display the network failure or retry the visit 166 | } 167 | } 168 | ``` 169 | 170 | HTTP error codes are a good way for the server to communicate specific requirements to your Turbolinks application. For example, you might use a `401 Unauthorized` response as a signal to prompt the user for authentication. 171 | 172 | See the demo app’s [ApplicationController](TurbolinksDemo/ApplicationController.swift) for a detailed example of how to present error messages and perform authorization. 173 | 174 | ## Setting Visitable Titles 175 | 176 | By default, Turbolinks for iOS sets your Visitable view controller’s `title` property to the page’s ``. 177 | 178 | If you want to customize the title or pull it from another element on the page, you can implement the `visitableDidRender` method on your Visitable: 179 | 180 | ```swift 181 | func visitableDidRender() { 182 | title = formatTitle(visitableView.webView?.title) 183 | } 184 | 185 | func formatTitle(title: String) -> String { 186 | // ... 187 | } 188 | ``` 189 | 190 | ## Starting and Stopping the Global Network Activity Indicator 191 | 192 | Implement the optional `sessionDidStartRequest:` and `sessionDidFinishRequest:` methods in your application’s Session delegate to show the global network activity indicator in the status bar while Turbolinks issues network requests. 193 | 194 | ```swift 195 | func sessionDidStartRequest(_ session: Session) { 196 | UIApplication.shared.isNetworkActivityIndicatorVisible = true 197 | } 198 | 199 | func sessionDidFinishRequest(_ session: Session) { 200 | UIApplication.shared.isNetworkActivityIndicatorVisible = false 201 | } 202 | ``` 203 | 204 | Note that the network activity indicator is a shared resource, so your application will need to perform its own reference counting if other background operations update the indicator state. 205 | 206 | ## Changing How Turbolinks Opens External URLs 207 | 208 | By default, Turbolinks for iOS opens external URLs in Safari. You can change this behavior by implementing the Session delegate’s optional `session:openExternalURL:` method. 209 | 210 | For example, to open external URLs in an in-app [SFSafariViewController](https://developer.apple.com/library/ios/documentation/SafariServices/Reference/SFSafariViewController_Ref/index.html), you might write: 211 | 212 | ```swift 213 | import SafariServices 214 | 215 | // ... 216 | 217 | func session(session: Session, openExternalURL URL: NSURL) { 218 | let safariViewController = SFSafariViewController(URL: URL) 219 | presentViewController(safariViewController, animated: true, completion: nil) 220 | } 221 | ``` 222 | 223 | ### Becoming the Web View’s Navigation Delegate 224 | 225 | Your application may require precise control over the web view’s navigation policy. If so, you can assign yourself as the WKWebView’s `navigationDelegate` and implement the [`webView:decidePolicyForNavigationAction:decisionHandler:`](https://developer.apple.com/library/ios/documentation/WebKit/Reference/WKNavigationDelegate_Ref/#//apple_ref/occ/intfm/WKNavigationDelegate/webView:decidePolicyForNavigationAction:decisionHandler:) method. 226 | 227 | To assign the web view’s `navigationDelegate` property, implement the Session delegate’s optional `sessionDidLoadWebView:` method. Turbolinks calls this method after every “cold boot,” such as on the initial page load and after pulling to refresh the page. 228 | 229 | ```swift 230 | func sessionDidLoadWebView(_ session: Session) { 231 | session.webView.navigationDelegate = self 232 | } 233 | 234 | func webView(webView: WKWebView, decidePolicyForNavigationAction navigationAction: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> ()) { 235 | decisionHandler(WKNavigationActionPolicy.Cancel) 236 | // ... 237 | } 238 | ``` 239 | 240 | Once you assign your own navigation delegate, Turbolinks will no longer invoke the Session delegate’s `session:openExternalURL:` method. 241 | 242 | Note that your application _must_ call the navigation delegate’s `decisionHandler` with `WKNavigationActionPolicy.Cancel` for main-frame navigation to prevent external URLs from loading in the Turbolinks-managed web view. 243 | 244 | ## Customizing the Web View Configuration 245 | 246 | Turbolinks allows your application to provide a [WKWebViewConfiguration](https://developer.apple.com/library/ios/documentation/WebKit/Reference/WKWebViewConfiguration_Ref/index.html) when you instantiate a Session. Use this configuration to set a custom user agent, share cookies with other web views, or install custom JavaScript message handlers. 247 | 248 | ```swift 249 | let configuration = WKWebViewConfiguration() 250 | let session = Session(webViewConfiguration: configuration) 251 | ``` 252 | 253 | Note that changing this configuration after creating the Session has no effect. 254 | 255 | ### Setting a Custom User Agent 256 | 257 | Set the `applicationNameForUserAgent` property to include a custom string in the `User-Agent` header. You can check for this string on the server and use it to send specialized markup or assets to your application. 258 | 259 | ```swift 260 | configuration.applicationNameForUserAgent = "MyApplication" 261 | ``` 262 | 263 | ### Sharing Cookies with Other Web Views 264 | 265 | If you’re using a separate web view for authentication purposes, or if your application has more than one Turbolinks Session, you can use a single [WKProcessPool](https://developer.apple.com/library/ios/documentation/WebKit/Reference/WKProcessPool_Ref/index.html) to share cookies across all web views. 266 | 267 | Create and retain a reference to a process pool in your application. Then configure your Turbolinks Session and any other web views you create to use this process pool. 268 | 269 | ```swift 270 | let processPool = WKProcessPool() 271 | // ... 272 | configuration.processPool = processPool 273 | ``` 274 | 275 | ### Passing Messages from JavaScript to Your Application 276 | 277 | You can register a [WKScriptMessageHandler](https://developer.apple.com/library/ios/documentation/WebKit/Reference/WKScriptMessageHandler_Ref/index.html) on the configuration’s user content controller to send messages from JavaScript to your iOS application. 278 | 279 | ```swift 280 | class ScriptMessageHandler: WKScriptMessageHandler { 281 | func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) { 282 | // ... 283 | } 284 | } 285 | 286 | let scriptMessageHandler = ScriptMessageHandler() 287 | configuration.userContentController.addScriptMessageHandler(scriptMessageHandler, name: "myApplication") 288 | ``` 289 | 290 | ```js 291 | document.addEventListener("click", function() { 292 | webkit.messageHandlers.myApplication.postMessage("Hello!") 293 | }) 294 | ``` 295 | 296 | 297 | # Contributing to Turbolinks 298 | 299 | Turbolinks for iOS is open-source software, freely distributable under the terms of an [MIT-style license](LICENSE). The [source code is hosted on GitHub](https://github.com/turbolinks/turbolinks-ios). 300 | Development is sponsored by [Basecamp](https://basecamp.com/). 301 | 302 | We welcome contributions in the form of bug reports, pull requests, or thoughtful discussions in the [GitHub issue tracker](https://github.com/turbolinks/turbolinks-ios/issues). 303 | 304 | Please note that this project is released with a [Contributor Code of Conduct](CONDUCT.md). By participating in this project you agree to abide by its terms. 305 | 306 | --- 307 | 308 | © 2017 Basecamp, LLC 309 | -------------------------------------------------------------------------------- /Turbolinks.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 1F8FC6B31B55A37400A781C8 /* WebView.js in Resources */ = {isa = PBXBuildFile; fileRef = 1F8FC6AE1B55A37400A781C8 /* WebView.js */; }; 11 | 1F8FC6B71B55A37400A781C8 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8FC6B01B55A37400A781C8 /* Session.swift */; }; 12 | 1F8FC6B91B55A37400A781C8 /* Visit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8FC6B11B55A37400A781C8 /* Visit.swift */; }; 13 | 1F8FC6BB1B55A37400A781C8 /* Visitable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8FC6B21B55A37400A781C8 /* Visitable.swift */; }; 14 | 2286D72F1C81FCF500E34B1D /* VisitableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2286D72E1C81FCF500E34B1D /* VisitableView.swift */; }; 15 | 2286D75A1C83F7C600E34B1D /* VisitableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2286D7591C83F7C600E34B1D /* VisitableViewController.swift */; }; 16 | 22960AAB1B9F51E100AA144A /* ScriptMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22960AAA1B9F51E100AA144A /* ScriptMessage.swift */; }; 17 | 22C197DE1B59835300749B4E /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C197DD1B59835300749B4E /* WebView.swift */; }; 18 | 22C81A1D1BA72CDA00E47138 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C81A1C1BA72CDA00E47138 /* Action.swift */; }; 19 | 22F1C91F1BB1FD6A008FDFFB /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F1C91E1BB1FD6A008FDFFB /* Error.swift */; }; 20 | 92DF450C1C80F7970064E606 /* Turbolinks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FC167EF1B55A14900AA6F43 /* Turbolinks.framework */; }; 21 | 92DF45131C80FA190064E606 /* ScriptMessageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DF45121C80FA190064E606 /* ScriptMessageTest.swift */; }; 22 | /* End PBXBuildFile section */ 23 | 24 | /* Begin PBXContainerItemProxy section */ 25 | 92DF450D1C80F7970064E606 /* PBXContainerItemProxy */ = { 26 | isa = PBXContainerItemProxy; 27 | containerPortal = 1FC167E61B55A14900AA6F43 /* Project object */; 28 | proxyType = 1; 29 | remoteGlobalIDString = 1FC167EE1B55A14900AA6F43; 30 | remoteInfo = Turbolinks; 31 | }; 32 | /* End PBXContainerItemProxy section */ 33 | 34 | /* Begin PBXFileReference section */ 35 | 1F8FC6AE1B55A37400A781C8 /* WebView.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = WebView.js; sourceTree = "<group>"; }; 36 | 1F8FC6B01B55A37400A781C8 /* Session.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; }; 37 | 1F8FC6B11B55A37400A781C8 /* Visit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Visit.swift; sourceTree = "<group>"; }; 38 | 1F8FC6B21B55A37400A781C8 /* Visitable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Visitable.swift; sourceTree = "<group>"; }; 39 | 1FC167EF1B55A14900AA6F43 /* Turbolinks.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Turbolinks.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 40 | 1FC167F31B55A14900AA6F43 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 41 | 2286D72E1C81FCF500E34B1D /* VisitableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisitableView.swift; sourceTree = "<group>"; }; 42 | 2286D7591C83F7C600E34B1D /* VisitableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisitableViewController.swift; sourceTree = "<group>"; }; 43 | 22960AAA1B9F51E100AA144A /* ScriptMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptMessage.swift; sourceTree = "<group>"; }; 44 | 22C197DD1B59835300749B4E /* WebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = "<group>"; }; 45 | 22C81A1C1BA72CDA00E47138 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = "<group>"; }; 46 | 22F1C91E1BB1FD6A008FDFFB /* Error.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = "<group>"; }; 47 | 92DF45071C80F7970064E606 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 48 | 92DF450B1C80F7970064E606 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 49 | 92DF45121C80FA190064E606 /* ScriptMessageTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptMessageTest.swift; sourceTree = "<group>"; }; 50 | /* End PBXFileReference section */ 51 | 52 | /* Begin PBXFrameworksBuildPhase section */ 53 | 1FC167EB1B55A14900AA6F43 /* Frameworks */ = { 54 | isa = PBXFrameworksBuildPhase; 55 | buildActionMask = 2147483647; 56 | files = ( 57 | ); 58 | runOnlyForDeploymentPostprocessing = 0; 59 | }; 60 | 92DF45041C80F7960064E606 /* Frameworks */ = { 61 | isa = PBXFrameworksBuildPhase; 62 | buildActionMask = 2147483647; 63 | files = ( 64 | 92DF450C1C80F7970064E606 /* Turbolinks.framework in Frameworks */, 65 | ); 66 | runOnlyForDeploymentPostprocessing = 0; 67 | }; 68 | /* End PBXFrameworksBuildPhase section */ 69 | 70 | /* Begin PBXGroup section */ 71 | 1FC167E51B55A14900AA6F43 = { 72 | isa = PBXGroup; 73 | children = ( 74 | 1FC167F11B55A14900AA6F43 /* Turbolinks */, 75 | 92DF45081C80F7970064E606 /* Tests */, 76 | 1FC167F01B55A14900AA6F43 /* Products */, 77 | ); 78 | sourceTree = "<group>"; 79 | }; 80 | 1FC167F01B55A14900AA6F43 /* Products */ = { 81 | isa = PBXGroup; 82 | children = ( 83 | 1FC167EF1B55A14900AA6F43 /* Turbolinks.framework */, 84 | 92DF45071C80F7970064E606 /* Tests.xctest */, 85 | ); 86 | name = Products; 87 | sourceTree = "<group>"; 88 | }; 89 | 1FC167F11B55A14900AA6F43 /* Turbolinks */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | 22C81A1C1BA72CDA00E47138 /* Action.swift */, 93 | 22F1C91E1BB1FD6A008FDFFB /* Error.swift */, 94 | 22960AAA1B9F51E100AA144A /* ScriptMessage.swift */, 95 | 1F8FC6B01B55A37400A781C8 /* Session.swift */, 96 | 1F8FC6B11B55A37400A781C8 /* Visit.swift */, 97 | 1F8FC6B21B55A37400A781C8 /* Visitable.swift */, 98 | 2286D72E1C81FCF500E34B1D /* VisitableView.swift */, 99 | 2286D7591C83F7C600E34B1D /* VisitableViewController.swift */, 100 | 22C197DD1B59835300749B4E /* WebView.swift */, 101 | 1FC167F21B55A14900AA6F43 /* Supporting Files */, 102 | ); 103 | path = Turbolinks; 104 | sourceTree = "<group>"; 105 | }; 106 | 1FC167F21B55A14900AA6F43 /* Supporting Files */ = { 107 | isa = PBXGroup; 108 | children = ( 109 | 1F8FC6AE1B55A37400A781C8 /* WebView.js */, 110 | 1FC167F31B55A14900AA6F43 /* Info.plist */, 111 | ); 112 | name = "Supporting Files"; 113 | sourceTree = "<group>"; 114 | }; 115 | 92DF45081C80F7970064E606 /* Tests */ = { 116 | isa = PBXGroup; 117 | children = ( 118 | 92DF450B1C80F7970064E606 /* Info.plist */, 119 | 92DF45121C80FA190064E606 /* ScriptMessageTest.swift */, 120 | ); 121 | name = Tests; 122 | path = Turbolinks/Tests; 123 | sourceTree = "<group>"; 124 | }; 125 | /* End PBXGroup section */ 126 | 127 | /* Begin PBXHeadersBuildPhase section */ 128 | 1FC167EC1B55A14900AA6F43 /* Headers */ = { 129 | isa = PBXHeadersBuildPhase; 130 | buildActionMask = 2147483647; 131 | files = ( 132 | ); 133 | runOnlyForDeploymentPostprocessing = 0; 134 | }; 135 | /* End PBXHeadersBuildPhase section */ 136 | 137 | /* Begin PBXNativeTarget section */ 138 | 1FC167EE1B55A14900AA6F43 /* Turbolinks */ = { 139 | isa = PBXNativeTarget; 140 | buildConfigurationList = 1FC168051B55A14900AA6F43 /* Build configuration list for PBXNativeTarget "Turbolinks" */; 141 | buildPhases = ( 142 | 1FC167EA1B55A14900AA6F43 /* Sources */, 143 | 1FC167EB1B55A14900AA6F43 /* Frameworks */, 144 | 1FC167EC1B55A14900AA6F43 /* Headers */, 145 | 1FC167ED1B55A14900AA6F43 /* Resources */, 146 | ); 147 | buildRules = ( 148 | ); 149 | dependencies = ( 150 | ); 151 | name = Turbolinks; 152 | productName = Turbolinks; 153 | productReference = 1FC167EF1B55A14900AA6F43 /* Turbolinks.framework */; 154 | productType = "com.apple.product-type.framework"; 155 | }; 156 | 92DF45061C80F7960064E606 /* Tests */ = { 157 | isa = PBXNativeTarget; 158 | buildConfigurationList = 92DF45111C80F7970064E606 /* Build configuration list for PBXNativeTarget "Tests" */; 159 | buildPhases = ( 160 | 92DF45031C80F7960064E606 /* Sources */, 161 | 92DF45041C80F7960064E606 /* Frameworks */, 162 | 92DF45051C80F7960064E606 /* Resources */, 163 | ); 164 | buildRules = ( 165 | ); 166 | dependencies = ( 167 | 92DF450E1C80F7970064E606 /* PBXTargetDependency */, 168 | ); 169 | name = Tests; 170 | productName = Tests; 171 | productReference = 92DF45071C80F7970064E606 /* Tests.xctest */; 172 | productType = "com.apple.product-type.bundle.unit-test"; 173 | }; 174 | /* End PBXNativeTarget section */ 175 | 176 | /* Begin PBXProject section */ 177 | 1FC167E61B55A14900AA6F43 /* Project object */ = { 178 | isa = PBXProject; 179 | attributes = { 180 | LastSwiftMigration = 0700; 181 | LastSwiftUpdateCheck = 0720; 182 | LastUpgradeCheck = 0900; 183 | ORGANIZATIONNAME = Basecamp; 184 | TargetAttributes = { 185 | 1FC167EE1B55A14900AA6F43 = { 186 | CreatedOnToolsVersion = 6.3.2; 187 | LastSwiftMigration = 0910; 188 | }; 189 | 92DF45061C80F7960064E606 = { 190 | CreatedOnToolsVersion = 7.2.1; 191 | LastSwiftMigration = 0910; 192 | }; 193 | }; 194 | }; 195 | buildConfigurationList = 1FC167E91B55A14900AA6F43 /* Build configuration list for PBXProject "Turbolinks" */; 196 | compatibilityVersion = "Xcode 3.2"; 197 | developmentRegion = English; 198 | hasScannedForEncodings = 0; 199 | knownRegions = ( 200 | en, 201 | ); 202 | mainGroup = 1FC167E51B55A14900AA6F43; 203 | productRefGroup = 1FC167F01B55A14900AA6F43 /* Products */; 204 | projectDirPath = ""; 205 | projectRoot = ""; 206 | targets = ( 207 | 1FC167EE1B55A14900AA6F43 /* Turbolinks */, 208 | 92DF45061C80F7960064E606 /* Tests */, 209 | ); 210 | }; 211 | /* End PBXProject section */ 212 | 213 | /* Begin PBXResourcesBuildPhase section */ 214 | 1FC167ED1B55A14900AA6F43 /* Resources */ = { 215 | isa = PBXResourcesBuildPhase; 216 | buildActionMask = 2147483647; 217 | files = ( 218 | 1F8FC6B31B55A37400A781C8 /* WebView.js in Resources */, 219 | ); 220 | runOnlyForDeploymentPostprocessing = 0; 221 | }; 222 | 92DF45051C80F7960064E606 /* Resources */ = { 223 | isa = PBXResourcesBuildPhase; 224 | buildActionMask = 2147483647; 225 | files = ( 226 | ); 227 | runOnlyForDeploymentPostprocessing = 0; 228 | }; 229 | /* End PBXResourcesBuildPhase section */ 230 | 231 | /* Begin PBXSourcesBuildPhase section */ 232 | 1FC167EA1B55A14900AA6F43 /* Sources */ = { 233 | isa = PBXSourcesBuildPhase; 234 | buildActionMask = 2147483647; 235 | files = ( 236 | 2286D72F1C81FCF500E34B1D /* VisitableView.swift in Sources */, 237 | 22960AAB1B9F51E100AA144A /* ScriptMessage.swift in Sources */, 238 | 2286D75A1C83F7C600E34B1D /* VisitableViewController.swift in Sources */, 239 | 1F8FC6B71B55A37400A781C8 /* Session.swift in Sources */, 240 | 1F8FC6BB1B55A37400A781C8 /* Visitable.swift in Sources */, 241 | 22C197DE1B59835300749B4E /* WebView.swift in Sources */, 242 | 1F8FC6B91B55A37400A781C8 /* Visit.swift in Sources */, 243 | 22C81A1D1BA72CDA00E47138 /* Action.swift in Sources */, 244 | 22F1C91F1BB1FD6A008FDFFB /* Error.swift in Sources */, 245 | ); 246 | runOnlyForDeploymentPostprocessing = 0; 247 | }; 248 | 92DF45031C80F7960064E606 /* Sources */ = { 249 | isa = PBXSourcesBuildPhase; 250 | buildActionMask = 2147483647; 251 | files = ( 252 | 92DF45131C80FA190064E606 /* ScriptMessageTest.swift in Sources */, 253 | ); 254 | runOnlyForDeploymentPostprocessing = 0; 255 | }; 256 | /* End PBXSourcesBuildPhase section */ 257 | 258 | /* Begin PBXTargetDependency section */ 259 | 92DF450E1C80F7970064E606 /* PBXTargetDependency */ = { 260 | isa = PBXTargetDependency; 261 | target = 1FC167EE1B55A14900AA6F43 /* Turbolinks */; 262 | targetProxy = 92DF450D1C80F7970064E606 /* PBXContainerItemProxy */; 263 | }; 264 | /* End PBXTargetDependency section */ 265 | 266 | /* Begin XCBuildConfiguration section */ 267 | 1FC168031B55A14900AA6F43 /* Debug */ = { 268 | isa = XCBuildConfiguration; 269 | buildSettings = { 270 | ALWAYS_SEARCH_USER_PATHS = NO; 271 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 272 | CLANG_CXX_LIBRARY = "libc++"; 273 | CLANG_ENABLE_MODULES = YES; 274 | CLANG_ENABLE_OBJC_ARC = YES; 275 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 276 | CLANG_WARN_BOOL_CONVERSION = YES; 277 | CLANG_WARN_COMMA = YES; 278 | CLANG_WARN_CONSTANT_CONVERSION = YES; 279 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 280 | CLANG_WARN_EMPTY_BODY = YES; 281 | CLANG_WARN_ENUM_CONVERSION = YES; 282 | CLANG_WARN_INFINITE_RECURSION = YES; 283 | CLANG_WARN_INT_CONVERSION = YES; 284 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 285 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 286 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 287 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 288 | CLANG_WARN_STRICT_PROTOTYPES = YES; 289 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 290 | CLANG_WARN_UNREACHABLE_CODE = YES; 291 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 292 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 293 | COPY_PHASE_STRIP = NO; 294 | CURRENT_PROJECT_VERSION = 1; 295 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 296 | ENABLE_STRICT_OBJC_MSGSEND = YES; 297 | ENABLE_TESTABILITY = YES; 298 | GCC_C_LANGUAGE_STANDARD = gnu99; 299 | GCC_DYNAMIC_NO_PIC = NO; 300 | GCC_NO_COMMON_BLOCKS = YES; 301 | GCC_OPTIMIZATION_LEVEL = 0; 302 | GCC_PREPROCESSOR_DEFINITIONS = ( 303 | "DEBUG=1", 304 | "$(inherited)", 305 | ); 306 | GCC_SYMBOLS_PRIVATE_EXTERN = NO; 307 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 308 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 309 | GCC_WARN_UNDECLARED_SELECTOR = YES; 310 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 311 | GCC_WARN_UNUSED_FUNCTION = YES; 312 | GCC_WARN_UNUSED_VARIABLE = YES; 313 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 314 | MTL_ENABLE_DEBUG_INFO = YES; 315 | ONLY_ACTIVE_ARCH = YES; 316 | SDKROOT = iphoneos; 317 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 318 | TARGETED_DEVICE_FAMILY = "1,2"; 319 | VERSIONING_SYSTEM = "apple-generic"; 320 | VERSION_INFO_PREFIX = ""; 321 | }; 322 | name = Debug; 323 | }; 324 | 1FC168041B55A14900AA6F43 /* Release */ = { 325 | isa = XCBuildConfiguration; 326 | buildSettings = { 327 | ALWAYS_SEARCH_USER_PATHS = NO; 328 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 329 | CLANG_CXX_LIBRARY = "libc++"; 330 | CLANG_ENABLE_MODULES = YES; 331 | CLANG_ENABLE_OBJC_ARC = YES; 332 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 333 | CLANG_WARN_BOOL_CONVERSION = YES; 334 | CLANG_WARN_COMMA = YES; 335 | CLANG_WARN_CONSTANT_CONVERSION = YES; 336 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 337 | CLANG_WARN_EMPTY_BODY = YES; 338 | CLANG_WARN_ENUM_CONVERSION = YES; 339 | CLANG_WARN_INFINITE_RECURSION = YES; 340 | CLANG_WARN_INT_CONVERSION = YES; 341 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 342 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 343 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 344 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 345 | CLANG_WARN_STRICT_PROTOTYPES = YES; 346 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 347 | CLANG_WARN_UNREACHABLE_CODE = YES; 348 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 349 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 350 | COPY_PHASE_STRIP = NO; 351 | CURRENT_PROJECT_VERSION = 1; 352 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 353 | ENABLE_NS_ASSERTIONS = NO; 354 | ENABLE_STRICT_OBJC_MSGSEND = YES; 355 | GCC_C_LANGUAGE_STANDARD = gnu99; 356 | GCC_NO_COMMON_BLOCKS = YES; 357 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 358 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 359 | GCC_WARN_UNDECLARED_SELECTOR = YES; 360 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 361 | GCC_WARN_UNUSED_FUNCTION = YES; 362 | GCC_WARN_UNUSED_VARIABLE = YES; 363 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 364 | MTL_ENABLE_DEBUG_INFO = NO; 365 | SDKROOT = iphoneos; 366 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 367 | TARGETED_DEVICE_FAMILY = "1,2"; 368 | VALIDATE_PRODUCT = YES; 369 | VERSIONING_SYSTEM = "apple-generic"; 370 | VERSION_INFO_PREFIX = ""; 371 | }; 372 | name = Release; 373 | }; 374 | 1FC168061B55A14900AA6F43 /* Debug */ = { 375 | isa = XCBuildConfiguration; 376 | buildSettings = { 377 | CLANG_ENABLE_MODULES = YES; 378 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; 379 | DEFINES_MODULE = YES; 380 | DYLIB_COMPATIBILITY_VERSION = 1; 381 | DYLIB_CURRENT_VERSION = 1; 382 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 383 | INFOPLIST_FILE = Turbolinks/Info.plist; 384 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 385 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 386 | PRODUCT_BUNDLE_IDENTIFIER = "com.basecamp.$(PRODUCT_NAME:rfc1034identifier)"; 387 | PRODUCT_NAME = "$(TARGET_NAME)"; 388 | SKIP_INSTALL = YES; 389 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 390 | SWIFT_VERSION = 4.0; 391 | }; 392 | name = Debug; 393 | }; 394 | 1FC168071B55A14900AA6F43 /* Release */ = { 395 | isa = XCBuildConfiguration; 396 | buildSettings = { 397 | CLANG_ENABLE_MODULES = YES; 398 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; 399 | DEFINES_MODULE = YES; 400 | DYLIB_COMPATIBILITY_VERSION = 1; 401 | DYLIB_CURRENT_VERSION = 1; 402 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 403 | INFOPLIST_FILE = Turbolinks/Info.plist; 404 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 405 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 406 | PRODUCT_BUNDLE_IDENTIFIER = "com.basecamp.$(PRODUCT_NAME:rfc1034identifier)"; 407 | PRODUCT_NAME = "$(TARGET_NAME)"; 408 | SKIP_INSTALL = YES; 409 | SWIFT_VERSION = 4.0; 410 | }; 411 | name = Release; 412 | }; 413 | 92DF450F1C80F7970064E606 /* Debug */ = { 414 | isa = XCBuildConfiguration; 415 | buildSettings = { 416 | CLANG_ENABLE_MODULES = YES; 417 | DEBUG_INFORMATION_FORMAT = dwarf; 418 | INFOPLIST_FILE = Turbolinks/Tests/Info.plist; 419 | IPHONEOS_DEPLOYMENT_TARGET = 9.2; 420 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 421 | PRODUCT_BUNDLE_IDENTIFIER = com.basecamp.Turbolinks.Tests; 422 | PRODUCT_NAME = "$(TARGET_NAME)"; 423 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 424 | SWIFT_VERSION = 4.0; 425 | }; 426 | name = Debug; 427 | }; 428 | 92DF45101C80F7970064E606 /* Release */ = { 429 | isa = XCBuildConfiguration; 430 | buildSettings = { 431 | CLANG_ENABLE_MODULES = YES; 432 | INFOPLIST_FILE = Turbolinks/Tests/Info.plist; 433 | IPHONEOS_DEPLOYMENT_TARGET = 9.2; 434 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 435 | PRODUCT_BUNDLE_IDENTIFIER = com.basecamp.Turbolinks.Tests; 436 | PRODUCT_NAME = "$(TARGET_NAME)"; 437 | SWIFT_VERSION = 4.0; 438 | }; 439 | name = Release; 440 | }; 441 | /* End XCBuildConfiguration section */ 442 | 443 | /* Begin XCConfigurationList section */ 444 | 1FC167E91B55A14900AA6F43 /* Build configuration list for PBXProject "Turbolinks" */ = { 445 | isa = XCConfigurationList; 446 | buildConfigurations = ( 447 | 1FC168031B55A14900AA6F43 /* Debug */, 448 | 1FC168041B55A14900AA6F43 /* Release */, 449 | ); 450 | defaultConfigurationIsVisible = 0; 451 | defaultConfigurationName = Release; 452 | }; 453 | 1FC168051B55A14900AA6F43 /* Build configuration list for PBXNativeTarget "Turbolinks" */ = { 454 | isa = XCConfigurationList; 455 | buildConfigurations = ( 456 | 1FC168061B55A14900AA6F43 /* Debug */, 457 | 1FC168071B55A14900AA6F43 /* Release */, 458 | ); 459 | defaultConfigurationIsVisible = 0; 460 | defaultConfigurationName = Release; 461 | }; 462 | 92DF45111C80F7970064E606 /* Build configuration list for PBXNativeTarget "Tests" */ = { 463 | isa = XCConfigurationList; 464 | buildConfigurations = ( 465 | 92DF450F1C80F7970064E606 /* Debug */, 466 | 92DF45101C80F7970064E606 /* Release */, 467 | ); 468 | defaultConfigurationIsVisible = 0; 469 | defaultConfigurationName = Release; 470 | }; 471 | /* End XCConfigurationList section */ 472 | }; 473 | rootObject = 1FC167E61B55A14900AA6F43 /* Project object */; 474 | } 475 | -------------------------------------------------------------------------------- /TurbolinksDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 1F35399F1B6FB21A00AA7462 /* ApplicationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F35399E1B6FB21A00AA7462 /* ApplicationController.swift */; }; 11 | 1F5A34FA1B67FF900029FDF3 /* AuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5A34F91B67FF900029FDF3 /* AuthenticationController.swift */; }; 12 | 1F8FC6A51B55A32000A781C8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8FC6A41B55A32000A781C8 /* AppDelegate.swift */; }; 13 | 1F8FC6AC1B55A34500A781C8 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1F8FC6AA1B55A34500A781C8 /* Main.storyboard */; }; 14 | 1FC168241B55A16C00AA6F43 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1FC168221B55A16C00AA6F43 /* LaunchScreen.xib */; }; 15 | 2230A7E91C869396001B4AC1 /* DemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2230A7E81C869396001B4AC1 /* DemoViewController.swift */; }; 16 | 2230A7EB1C8694EC001B4AC1 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2230A7EA1C8694EC001B4AC1 /* ErrorView.swift */; }; 17 | 2230A7ED1C869BD2001B4AC1 /* ErrorView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2230A7EC1C869BD2001B4AC1 /* ErrorView.xib */; }; 18 | 2230A7EF1C874108001B4AC1 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2230A7EE1C874108001B4AC1 /* Error.swift */; }; 19 | 22CB5D901C62981700F24EA7 /* Turbolinks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 22CB5D8D1C62980000F24EA7 /* Turbolinks.framework */; }; 20 | 22CB5D911C62982800F24EA7 /* Turbolinks.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 22CB5D8D1C62980000F24EA7 /* Turbolinks.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 21 | 8442C5A81C9F83D200FD8CC7 /* UITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442C5A71C9F83D200FD8CC7 /* UITests.swift */; }; 22 | 92DF45171C81149B0064E606 /* NumbersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DF45161C81149B0064E606 /* NumbersViewController.swift */; }; 23 | /* End PBXBuildFile section */ 24 | 25 | /* Begin PBXContainerItemProxy section */ 26 | 22CB5D8C1C62980000F24EA7 /* PBXContainerItemProxy */ = { 27 | isa = PBXContainerItemProxy; 28 | containerPortal = 22CB5D881C62980000F24EA7 /* Turbolinks.xcodeproj */; 29 | proxyType = 2; 30 | remoteGlobalIDString = 1FC167EF1B55A14900AA6F43; 31 | remoteInfo = Turbolinks; 32 | }; 33 | 22CB5D8E1C62980900F24EA7 /* PBXContainerItemProxy */ = { 34 | isa = PBXContainerItemProxy; 35 | containerPortal = 22CB5D881C62980000F24EA7 /* Turbolinks.xcodeproj */; 36 | proxyType = 1; 37 | remoteGlobalIDString = 1FC167EE1B55A14900AA6F43; 38 | remoteInfo = Turbolinks; 39 | }; 40 | 8442C5AA1C9F83D200FD8CC7 /* PBXContainerItemProxy */ = { 41 | isa = PBXContainerItemProxy; 42 | containerPortal = 1FC1680C1B55A16C00AA6F43 /* Project object */; 43 | proxyType = 1; 44 | remoteGlobalIDString = 1FC168131B55A16C00AA6F43; 45 | remoteInfo = TurbolinksDemo; 46 | }; 47 | 92DF45191C81149C0064E606 /* PBXContainerItemProxy */ = { 48 | isa = PBXContainerItemProxy; 49 | containerPortal = 22CB5D881C62980000F24EA7 /* Turbolinks.xcodeproj */; 50 | proxyType = 2; 51 | remoteGlobalIDString = 92DF45071C80F7970064E606; 52 | remoteInfo = Tests; 53 | }; 54 | /* End PBXContainerItemProxy section */ 55 | 56 | /* Begin PBXCopyFilesBuildPhase section */ 57 | 22A398D81B58166600E34A4C /* Embed Frameworks */ = { 58 | isa = PBXCopyFilesBuildPhase; 59 | buildActionMask = 2147483647; 60 | dstPath = ""; 61 | dstSubfolderSpec = 10; 62 | files = ( 63 | 22CB5D911C62982800F24EA7 /* Turbolinks.framework in Embed Frameworks */, 64 | ); 65 | name = "Embed Frameworks"; 66 | runOnlyForDeploymentPostprocessing = 0; 67 | }; 68 | /* End PBXCopyFilesBuildPhase section */ 69 | 70 | /* Begin PBXFileReference section */ 71 | 1F35399E1B6FB21A00AA7462 /* ApplicationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationController.swift; sourceTree = "<group>"; }; 72 | 1F5A34F91B67FF900029FDF3 /* AuthenticationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationController.swift; sourceTree = "<group>"; }; 73 | 1F8FC6A41B55A32000A781C8 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74 | 1F8FC6AB1B55A34500A781C8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; 75 | 1FC168141B55A16C00AA6F43 /* TurbolinksDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TurbolinksDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 76 | 1FC168181B55A16C00AA6F43 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 77 | 1FC168231B55A16C00AA6F43 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = "<group>"; }; 78 | 2230A7E81C869396001B4AC1 /* DemoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoViewController.swift; sourceTree = "<group>"; }; 79 | 2230A7EA1C8694EC001B4AC1 /* ErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; }; 80 | 2230A7EC1C869BD2001B4AC1 /* ErrorView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ErrorView.xib; sourceTree = "<group>"; }; 81 | 2230A7EE1C874108001B4AC1 /* Error.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = "<group>"; }; 82 | 22CB5D881C62980000F24EA7 /* Turbolinks.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = Turbolinks.xcodeproj; sourceTree = "<group>"; }; 83 | 8442C5A51C9F83D200FD8CC7 /* UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 84 | 8442C5A71C9F83D200FD8CC7 /* UITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITests.swift; sourceTree = "<group>"; }; 85 | 8442C5A91C9F83D200FD8CC7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 86 | 92DF45161C81149B0064E606 /* NumbersViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumbersViewController.swift; sourceTree = "<group>"; }; 87 | /* End PBXFileReference section */ 88 | 89 | /* Begin PBXFrameworksBuildPhase section */ 90 | 1FC168111B55A16C00AA6F43 /* Frameworks */ = { 91 | isa = PBXFrameworksBuildPhase; 92 | buildActionMask = 2147483647; 93 | files = ( 94 | 22CB5D901C62981700F24EA7 /* Turbolinks.framework in Frameworks */, 95 | ); 96 | runOnlyForDeploymentPostprocessing = 0; 97 | }; 98 | 8442C5A21C9F83D100FD8CC7 /* Frameworks */ = { 99 | isa = PBXFrameworksBuildPhase; 100 | buildActionMask = 2147483647; 101 | files = ( 102 | ); 103 | runOnlyForDeploymentPostprocessing = 0; 104 | }; 105 | /* End PBXFrameworksBuildPhase section */ 106 | 107 | /* Begin PBXGroup section */ 108 | 1FC1680B1B55A16C00AA6F43 = { 109 | isa = PBXGroup; 110 | children = ( 111 | 1FC168161B55A16C00AA6F43 /* TurbolinksDemo */, 112 | 1FC168151B55A16C00AA6F43 /* Products */, 113 | 8442C5A61C9F83D200FD8CC7 /* UI Tests */, 114 | 22CB5D881C62980000F24EA7 /* Turbolinks.xcodeproj */, 115 | ); 116 | sourceTree = "<group>"; 117 | }; 118 | 1FC168151B55A16C00AA6F43 /* Products */ = { 119 | isa = PBXGroup; 120 | children = ( 121 | 1FC168141B55A16C00AA6F43 /* TurbolinksDemo.app */, 122 | 8442C5A51C9F83D200FD8CC7 /* UI Tests.xctest */, 123 | ); 124 | name = Products; 125 | sourceTree = "<group>"; 126 | }; 127 | 1FC168161B55A16C00AA6F43 /* TurbolinksDemo */ = { 128 | isa = PBXGroup; 129 | children = ( 130 | 1F8FC6A41B55A32000A781C8 /* AppDelegate.swift */, 131 | 1F35399E1B6FB21A00AA7462 /* ApplicationController.swift */, 132 | 1F5A34F91B67FF900029FDF3 /* AuthenticationController.swift */, 133 | 2230A7E81C869396001B4AC1 /* DemoViewController.swift */, 134 | 2230A7EE1C874108001B4AC1 /* Error.swift */, 135 | 2230A7EA1C8694EC001B4AC1 /* ErrorView.swift */, 136 | 92DF45161C81149B0064E606 /* NumbersViewController.swift */, 137 | 2230A7EC1C869BD2001B4AC1 /* ErrorView.xib */, 138 | 1FC168221B55A16C00AA6F43 /* LaunchScreen.xib */, 139 | 1F8FC6AA1B55A34500A781C8 /* Main.storyboard */, 140 | 1FC168171B55A16C00AA6F43 /* Supporting Files */, 141 | ); 142 | path = TurbolinksDemo; 143 | sourceTree = "<group>"; 144 | }; 145 | 1FC168171B55A16C00AA6F43 /* Supporting Files */ = { 146 | isa = PBXGroup; 147 | children = ( 148 | 1FC168181B55A16C00AA6F43 /* Info.plist */, 149 | ); 150 | name = "Supporting Files"; 151 | sourceTree = "<group>"; 152 | }; 153 | 22CB5D891C62980000F24EA7 /* Products */ = { 154 | isa = PBXGroup; 155 | children = ( 156 | 22CB5D8D1C62980000F24EA7 /* Turbolinks.framework */, 157 | 92DF451A1C81149C0064E606 /* Tests.xctest */, 158 | ); 159 | name = Products; 160 | sourceTree = "<group>"; 161 | }; 162 | 8442C5A61C9F83D200FD8CC7 /* UI Tests */ = { 163 | isa = PBXGroup; 164 | children = ( 165 | 8442C5A91C9F83D200FD8CC7 /* Info.plist */, 166 | 8442C5A71C9F83D200FD8CC7 /* UITests.swift */, 167 | ); 168 | name = "UI Tests"; 169 | path = TurbolinksDemo/UITests; 170 | sourceTree = "<group>"; 171 | }; 172 | /* End PBXGroup section */ 173 | 174 | /* Begin PBXNativeTarget section */ 175 | 1FC168131B55A16C00AA6F43 /* TurbolinksDemo */ = { 176 | isa = PBXNativeTarget; 177 | buildConfigurationList = 1FC168331B55A16C00AA6F43 /* Build configuration list for PBXNativeTarget "TurbolinksDemo" */; 178 | buildPhases = ( 179 | 1FC168101B55A16C00AA6F43 /* Sources */, 180 | 1FC168111B55A16C00AA6F43 /* Frameworks */, 181 | 1FC168121B55A16C00AA6F43 /* Resources */, 182 | 22A398D81B58166600E34A4C /* Embed Frameworks */, 183 | ); 184 | buildRules = ( 185 | ); 186 | dependencies = ( 187 | 22CB5D8F1C62980900F24EA7 /* PBXTargetDependency */, 188 | ); 189 | name = TurbolinksDemo; 190 | productName = TurbolinksDemo; 191 | productReference = 1FC168141B55A16C00AA6F43 /* TurbolinksDemo.app */; 192 | productType = "com.apple.product-type.application"; 193 | }; 194 | 8442C5A41C9F83D100FD8CC7 /* UI Tests */ = { 195 | isa = PBXNativeTarget; 196 | buildConfigurationList = 8442C5AF1C9F83D200FD8CC7 /* Build configuration list for PBXNativeTarget "UI Tests" */; 197 | buildPhases = ( 198 | 8442C5A11C9F83D100FD8CC7 /* Sources */, 199 | 8442C5A21C9F83D100FD8CC7 /* Frameworks */, 200 | 8442C5A31C9F83D100FD8CC7 /* Resources */, 201 | ); 202 | buildRules = ( 203 | ); 204 | dependencies = ( 205 | 8442C5AB1C9F83D200FD8CC7 /* PBXTargetDependency */, 206 | ); 207 | name = "UI Tests"; 208 | productName = "UI Tests"; 209 | productReference = 8442C5A51C9F83D200FD8CC7 /* UI Tests.xctest */; 210 | productType = "com.apple.product-type.bundle.ui-testing"; 211 | }; 212 | /* End PBXNativeTarget section */ 213 | 214 | /* Begin PBXProject section */ 215 | 1FC1680C1B55A16C00AA6F43 /* Project object */ = { 216 | isa = PBXProject; 217 | attributes = { 218 | LastSwiftMigration = 0700; 219 | LastSwiftUpdateCheck = 0700; 220 | LastUpgradeCheck = 0900; 221 | ORGANIZATIONNAME = Basecamp; 222 | TargetAttributes = { 223 | 1FC168131B55A16C00AA6F43 = { 224 | CreatedOnToolsVersion = 6.3.2; 225 | LastSwiftMigration = 0910; 226 | }; 227 | 8442C5A41C9F83D100FD8CC7 = { 228 | LastSwiftMigration = 0910; 229 | }; 230 | }; 231 | }; 232 | buildConfigurationList = 1FC1680F1B55A16C00AA6F43 /* Build configuration list for PBXProject "TurbolinksDemo" */; 233 | compatibilityVersion = "Xcode 3.2"; 234 | developmentRegion = English; 235 | hasScannedForEncodings = 0; 236 | knownRegions = ( 237 | en, 238 | Base, 239 | ); 240 | mainGroup = 1FC1680B1B55A16C00AA6F43; 241 | productRefGroup = 1FC168151B55A16C00AA6F43 /* Products */; 242 | projectDirPath = ""; 243 | projectReferences = ( 244 | { 245 | ProductGroup = 22CB5D891C62980000F24EA7 /* Products */; 246 | ProjectRef = 22CB5D881C62980000F24EA7 /* Turbolinks.xcodeproj */; 247 | }, 248 | ); 249 | projectRoot = ""; 250 | targets = ( 251 | 1FC168131B55A16C00AA6F43 /* TurbolinksDemo */, 252 | 8442C5A41C9F83D100FD8CC7 /* UI Tests */, 253 | ); 254 | }; 255 | /* End PBXProject section */ 256 | 257 | /* Begin PBXReferenceProxy section */ 258 | 22CB5D8D1C62980000F24EA7 /* Turbolinks.framework */ = { 259 | isa = PBXReferenceProxy; 260 | fileType = wrapper.framework; 261 | path = Turbolinks.framework; 262 | remoteRef = 22CB5D8C1C62980000F24EA7 /* PBXContainerItemProxy */; 263 | sourceTree = BUILT_PRODUCTS_DIR; 264 | }; 265 | 92DF451A1C81149C0064E606 /* Tests.xctest */ = { 266 | isa = PBXReferenceProxy; 267 | fileType = wrapper.cfbundle; 268 | path = Tests.xctest; 269 | remoteRef = 92DF45191C81149C0064E606 /* PBXContainerItemProxy */; 270 | sourceTree = BUILT_PRODUCTS_DIR; 271 | }; 272 | /* End PBXReferenceProxy section */ 273 | 274 | /* Begin PBXResourcesBuildPhase section */ 275 | 1FC168121B55A16C00AA6F43 /* Resources */ = { 276 | isa = PBXResourcesBuildPhase; 277 | buildActionMask = 2147483647; 278 | files = ( 279 | 1F8FC6AC1B55A34500A781C8 /* Main.storyboard in Resources */, 280 | 1FC168241B55A16C00AA6F43 /* LaunchScreen.xib in Resources */, 281 | 2230A7ED1C869BD2001B4AC1 /* ErrorView.xib in Resources */, 282 | ); 283 | runOnlyForDeploymentPostprocessing = 0; 284 | }; 285 | 8442C5A31C9F83D100FD8CC7 /* Resources */ = { 286 | isa = PBXResourcesBuildPhase; 287 | buildActionMask = 2147483647; 288 | files = ( 289 | ); 290 | runOnlyForDeploymentPostprocessing = 0; 291 | }; 292 | /* End PBXResourcesBuildPhase section */ 293 | 294 | /* Begin PBXSourcesBuildPhase section */ 295 | 1FC168101B55A16C00AA6F43 /* Sources */ = { 296 | isa = PBXSourcesBuildPhase; 297 | buildActionMask = 2147483647; 298 | files = ( 299 | 1F5A34FA1B67FF900029FDF3 /* AuthenticationController.swift in Sources */, 300 | 2230A7EB1C8694EC001B4AC1 /* ErrorView.swift in Sources */, 301 | 92DF45171C81149B0064E606 /* NumbersViewController.swift in Sources */, 302 | 1F8FC6A51B55A32000A781C8 /* AppDelegate.swift in Sources */, 303 | 1F35399F1B6FB21A00AA7462 /* ApplicationController.swift in Sources */, 304 | 2230A7EF1C874108001B4AC1 /* Error.swift in Sources */, 305 | 2230A7E91C869396001B4AC1 /* DemoViewController.swift in Sources */, 306 | ); 307 | runOnlyForDeploymentPostprocessing = 0; 308 | }; 309 | 8442C5A11C9F83D100FD8CC7 /* Sources */ = { 310 | isa = PBXSourcesBuildPhase; 311 | buildActionMask = 2147483647; 312 | files = ( 313 | 8442C5A81C9F83D200FD8CC7 /* UITests.swift in Sources */, 314 | ); 315 | runOnlyForDeploymentPostprocessing = 0; 316 | }; 317 | /* End PBXSourcesBuildPhase section */ 318 | 319 | /* Begin PBXTargetDependency section */ 320 | 22CB5D8F1C62980900F24EA7 /* PBXTargetDependency */ = { 321 | isa = PBXTargetDependency; 322 | name = Turbolinks; 323 | targetProxy = 22CB5D8E1C62980900F24EA7 /* PBXContainerItemProxy */; 324 | }; 325 | 8442C5AB1C9F83D200FD8CC7 /* PBXTargetDependency */ = { 326 | isa = PBXTargetDependency; 327 | target = 1FC168131B55A16C00AA6F43 /* TurbolinksDemo */; 328 | targetProxy = 8442C5AA1C9F83D200FD8CC7 /* PBXContainerItemProxy */; 329 | }; 330 | /* End PBXTargetDependency section */ 331 | 332 | /* Begin PBXVariantGroup section */ 333 | 1F8FC6AA1B55A34500A781C8 /* Main.storyboard */ = { 334 | isa = PBXVariantGroup; 335 | children = ( 336 | 1F8FC6AB1B55A34500A781C8 /* Base */, 337 | ); 338 | name = Main.storyboard; 339 | sourceTree = "<group>"; 340 | }; 341 | 1FC168221B55A16C00AA6F43 /* LaunchScreen.xib */ = { 342 | isa = PBXVariantGroup; 343 | children = ( 344 | 1FC168231B55A16C00AA6F43 /* Base */, 345 | ); 346 | name = LaunchScreen.xib; 347 | sourceTree = "<group>"; 348 | }; 349 | /* End PBXVariantGroup section */ 350 | 351 | /* Begin XCBuildConfiguration section */ 352 | 1FC168311B55A16C00AA6F43 /* Debug */ = { 353 | isa = XCBuildConfiguration; 354 | buildSettings = { 355 | ALWAYS_SEARCH_USER_PATHS = NO; 356 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 357 | CLANG_CXX_LIBRARY = "libc++"; 358 | CLANG_ENABLE_MODULES = YES; 359 | CLANG_ENABLE_OBJC_ARC = YES; 360 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 361 | CLANG_WARN_BOOL_CONVERSION = YES; 362 | CLANG_WARN_COMMA = YES; 363 | CLANG_WARN_CONSTANT_CONVERSION = YES; 364 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 365 | CLANG_WARN_EMPTY_BODY = YES; 366 | CLANG_WARN_ENUM_CONVERSION = YES; 367 | CLANG_WARN_INFINITE_RECURSION = YES; 368 | CLANG_WARN_INT_CONVERSION = YES; 369 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 370 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 371 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 372 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 373 | CLANG_WARN_STRICT_PROTOTYPES = YES; 374 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 375 | CLANG_WARN_UNREACHABLE_CODE = YES; 376 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 377 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 378 | COPY_PHASE_STRIP = NO; 379 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 380 | ENABLE_STRICT_OBJC_MSGSEND = YES; 381 | ENABLE_TESTABILITY = YES; 382 | GCC_C_LANGUAGE_STANDARD = gnu99; 383 | GCC_DYNAMIC_NO_PIC = NO; 384 | GCC_NO_COMMON_BLOCKS = YES; 385 | GCC_OPTIMIZATION_LEVEL = 0; 386 | GCC_PREPROCESSOR_DEFINITIONS = ( 387 | "DEBUG=1", 388 | "$(inherited)", 389 | ); 390 | GCC_SYMBOLS_PRIVATE_EXTERN = NO; 391 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 392 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 393 | GCC_WARN_UNDECLARED_SELECTOR = YES; 394 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 395 | GCC_WARN_UNUSED_FUNCTION = YES; 396 | GCC_WARN_UNUSED_VARIABLE = YES; 397 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 398 | MTL_ENABLE_DEBUG_INFO = YES; 399 | ONLY_ACTIVE_ARCH = YES; 400 | SDKROOT = iphoneos; 401 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 402 | }; 403 | name = Debug; 404 | }; 405 | 1FC168321B55A16C00AA6F43 /* Release */ = { 406 | isa = XCBuildConfiguration; 407 | buildSettings = { 408 | ALWAYS_SEARCH_USER_PATHS = NO; 409 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 410 | CLANG_CXX_LIBRARY = "libc++"; 411 | CLANG_ENABLE_MODULES = YES; 412 | CLANG_ENABLE_OBJC_ARC = YES; 413 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 414 | CLANG_WARN_BOOL_CONVERSION = YES; 415 | CLANG_WARN_COMMA = YES; 416 | CLANG_WARN_CONSTANT_CONVERSION = YES; 417 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 418 | CLANG_WARN_EMPTY_BODY = YES; 419 | CLANG_WARN_ENUM_CONVERSION = YES; 420 | CLANG_WARN_INFINITE_RECURSION = YES; 421 | CLANG_WARN_INT_CONVERSION = YES; 422 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 423 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 424 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 425 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 426 | CLANG_WARN_STRICT_PROTOTYPES = YES; 427 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 428 | CLANG_WARN_UNREACHABLE_CODE = YES; 429 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 430 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 431 | COPY_PHASE_STRIP = NO; 432 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 433 | ENABLE_NS_ASSERTIONS = NO; 434 | ENABLE_STRICT_OBJC_MSGSEND = YES; 435 | GCC_C_LANGUAGE_STANDARD = gnu99; 436 | GCC_NO_COMMON_BLOCKS = YES; 437 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 438 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 439 | GCC_WARN_UNDECLARED_SELECTOR = YES; 440 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 441 | GCC_WARN_UNUSED_FUNCTION = YES; 442 | GCC_WARN_UNUSED_VARIABLE = YES; 443 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 444 | MTL_ENABLE_DEBUG_INFO = NO; 445 | SDKROOT = iphoneos; 446 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 447 | VALIDATE_PRODUCT = YES; 448 | }; 449 | name = Release; 450 | }; 451 | 1FC168341B55A16C00AA6F43 /* Debug */ = { 452 | isa = XCBuildConfiguration; 453 | buildSettings = { 454 | CLANG_ENABLE_MODULES = YES; 455 | INFOPLIST_FILE = TurbolinksDemo/Info.plist; 456 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 457 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 458 | PRODUCT_BUNDLE_IDENTIFIER = "com.basecamp.$(PRODUCT_NAME:rfc1034identifier)"; 459 | PRODUCT_NAME = "$(TARGET_NAME)"; 460 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 461 | SWIFT_VERSION = 4.0; 462 | }; 463 | name = Debug; 464 | }; 465 | 1FC168351B55A16C00AA6F43 /* Release */ = { 466 | isa = XCBuildConfiguration; 467 | buildSettings = { 468 | CLANG_ENABLE_MODULES = YES; 469 | INFOPLIST_FILE = TurbolinksDemo/Info.plist; 470 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 471 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 472 | PRODUCT_BUNDLE_IDENTIFIER = "com.basecamp.$(PRODUCT_NAME:rfc1034identifier)"; 473 | PRODUCT_NAME = "$(TARGET_NAME)"; 474 | SWIFT_VERSION = 4.0; 475 | }; 476 | name = Release; 477 | }; 478 | 8442C5AC1C9F83D200FD8CC7 /* Debug */ = { 479 | isa = XCBuildConfiguration; 480 | buildSettings = { 481 | DEBUG_INFORMATION_FORMAT = dwarf; 482 | INFOPLIST_FILE = TurbolinksDemo/UITests/Info.plist; 483 | IPHONEOS_DEPLOYMENT_TARGET = 9.2; 484 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 485 | PRODUCT_BUNDLE_IDENTIFIER = com.basecamp.TurbolinksDemo.UITests; 486 | PRODUCT_NAME = "$(TARGET_NAME)"; 487 | SWIFT_VERSION = 4.0; 488 | TEST_TARGET_NAME = TurbolinksDemo; 489 | USES_XCTRUNNER = YES; 490 | }; 491 | name = Debug; 492 | }; 493 | 8442C5AD1C9F83D200FD8CC7 /* Release */ = { 494 | isa = XCBuildConfiguration; 495 | buildSettings = { 496 | INFOPLIST_FILE = TurbolinksDemo/UITests/Info.plist; 497 | IPHONEOS_DEPLOYMENT_TARGET = 9.2; 498 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 499 | PRODUCT_BUNDLE_IDENTIFIER = com.basecamp.TurbolinksDemo.UITests; 500 | PRODUCT_NAME = "$(TARGET_NAME)"; 501 | SWIFT_VERSION = 4.0; 502 | TEST_TARGET_NAME = TurbolinksDemo; 503 | USES_XCTRUNNER = YES; 504 | }; 505 | name = Release; 506 | }; 507 | /* End XCBuildConfiguration section */ 508 | 509 | /* Begin XCConfigurationList section */ 510 | 1FC1680F1B55A16C00AA6F43 /* Build configuration list for PBXProject "TurbolinksDemo" */ = { 511 | isa = XCConfigurationList; 512 | buildConfigurations = ( 513 | 1FC168311B55A16C00AA6F43 /* Debug */, 514 | 1FC168321B55A16C00AA6F43 /* Release */, 515 | ); 516 | defaultConfigurationIsVisible = 0; 517 | defaultConfigurationName = Release; 518 | }; 519 | 1FC168331B55A16C00AA6F43 /* Build configuration list for PBXNativeTarget "TurbolinksDemo" */ = { 520 | isa = XCConfigurationList; 521 | buildConfigurations = ( 522 | 1FC168341B55A16C00AA6F43 /* Debug */, 523 | 1FC168351B55A16C00AA6F43 /* Release */, 524 | ); 525 | defaultConfigurationIsVisible = 0; 526 | defaultConfigurationName = Release; 527 | }; 528 | 8442C5AF1C9F83D200FD8CC7 /* Build configuration list for PBXNativeTarget "UI Tests" */ = { 529 | isa = XCConfigurationList; 530 | buildConfigurations = ( 531 | 8442C5AC1C9F83D200FD8CC7 /* Debug */, 532 | 8442C5AD1C9F83D200FD8CC7 /* Release */, 533 | ); 534 | defaultConfigurationIsVisible = 0; 535 | defaultConfigurationName = Release; 536 | }; 537 | /* End XCConfigurationList section */ 538 | }; 539 | rootObject = 1FC1680C1B55A16C00AA6F43 /* Project object */; 540 | } 541 | --------------------------------------------------------------------------------