├── .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 |
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 |
31 | Rendered on <%= Time.now.strftime("%Y-%m-%d at %H:%M:%S") %>
32 |
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 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
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 | 
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 = ""; };
36 | 1F8FC6B01B55A37400A781C8 /* Session.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; };
37 | 1F8FC6B11B55A37400A781C8 /* Visit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Visit.swift; sourceTree = ""; };
38 | 1F8FC6B21B55A37400A781C8 /* Visitable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Visitable.swift; sourceTree = ""; };
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 = ""; };
41 | 2286D72E1C81FCF500E34B1D /* VisitableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisitableView.swift; sourceTree = ""; };
42 | 2286D7591C83F7C600E34B1D /* VisitableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisitableViewController.swift; sourceTree = ""; };
43 | 22960AAA1B9F51E100AA144A /* ScriptMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptMessage.swift; sourceTree = ""; };
44 | 22C197DD1B59835300749B4E /* WebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; };
45 | 22C81A1C1BA72CDA00E47138 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; };
46 | 22F1C91E1BB1FD6A008FDFFB /* Error.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; };
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 = ""; };
49 | 92DF45121C80FA190064E606 /* ScriptMessageTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptMessageTest.swift; sourceTree = ""; };
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 = "";
79 | };
80 | 1FC167F01B55A14900AA6F43 /* Products */ = {
81 | isa = PBXGroup;
82 | children = (
83 | 1FC167EF1B55A14900AA6F43 /* Turbolinks.framework */,
84 | 92DF45071C80F7970064E606 /* Tests.xctest */,
85 | );
86 | name = Products;
87 | sourceTree = "";
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 = "";
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 = "";
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 = "";
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 = ""; };
72 | 1F5A34F91B67FF900029FDF3 /* AuthenticationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationController.swift; sourceTree = ""; };
73 | 1F8FC6A41B55A32000A781C8 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
74 | 1F8FC6AB1B55A34500A781C8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
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 = ""; };
77 | 1FC168231B55A16C00AA6F43 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; };
78 | 2230A7E81C869396001B4AC1 /* DemoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoViewController.swift; sourceTree = ""; };
79 | 2230A7EA1C8694EC001B4AC1 /* ErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; };
80 | 2230A7EC1C869BD2001B4AC1 /* ErrorView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ErrorView.xib; sourceTree = ""; };
81 | 2230A7EE1C874108001B4AC1 /* Error.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; };
82 | 22CB5D881C62980000F24EA7 /* Turbolinks.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = Turbolinks.xcodeproj; sourceTree = ""; };
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 = ""; };
85 | 8442C5A91C9F83D200FD8CC7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
86 | 92DF45161C81149B0064E606 /* NumbersViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumbersViewController.swift; sourceTree = ""; };
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 = "";
117 | };
118 | 1FC168151B55A16C00AA6F43 /* Products */ = {
119 | isa = PBXGroup;
120 | children = (
121 | 1FC168141B55A16C00AA6F43 /* TurbolinksDemo.app */,
122 | 8442C5A51C9F83D200FD8CC7 /* UI Tests.xctest */,
123 | );
124 | name = Products;
125 | sourceTree = "";
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 = "";
144 | };
145 | 1FC168171B55A16C00AA6F43 /* Supporting Files */ = {
146 | isa = PBXGroup;
147 | children = (
148 | 1FC168181B55A16C00AA6F43 /* Info.plist */,
149 | );
150 | name = "Supporting Files";
151 | sourceTree = "";
152 | };
153 | 22CB5D891C62980000F24EA7 /* Products */ = {
154 | isa = PBXGroup;
155 | children = (
156 | 22CB5D8D1C62980000F24EA7 /* Turbolinks.framework */,
157 | 92DF451A1C81149C0064E606 /* Tests.xctest */,
158 | );
159 | name = Products;
160 | sourceTree = "";
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 = "";
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 = "";
340 | };
341 | 1FC168221B55A16C00AA6F43 /* LaunchScreen.xib */ = {
342 | isa = PBXVariantGroup;
343 | children = (
344 | 1FC168231B55A16C00AA6F43 /* Base */,
345 | );
346 | name = LaunchScreen.xib;
347 | sourceTree = "";
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 |
--------------------------------------------------------------------------------