This step-by-step tutorial is the quickest way to see Peregrine in action for yourself on iOS. As a reminder, you can use the buttons above to switch platforms for this documentation.

In the interest of saving time and reducing complexity, this tutorial is short on words and also not that practical. You are encouraged to embellish the code and be playful! Learning should be fun. 🤓

Create an example app

Use XCode to create a throwaway iOS app. Make sure to select SwiftUI for interface. Review these requirements for more information.

Install Peregrine

The first step is to install the Peregrine iOS library.

  1. In XCode, click on your project in the Project Navigator.
  2. Under “General”, find “Frameworks, Libraries, and Embedded Content” and click the add button.
  3. Click “Add Other…” and select “Add Package Dependency…”
  4. Paste the following URL into the search bar.
https://github.com/peregrinejs/Peregrine-iOS
  1. Click “Add Package” and make sure the Peregrine library is added to your app target.

Want to use CocoaPods?

Peregrine primarily uses Swift Package Manager, but you can also install it by adding the Peregrine pod to your Podfile.

target 'MyApp' do
  pod 'Peregrine', '~> 0.10'
end

Add web assets

Before we can use Peregrine, we need an index.html file and any accompanying web assets.

  1. In XCode, right click on your project in the Project Navigator and select “Show in Finder”.
  2. In the Finder window, create a new folder named www. This folder will serve as the root of the web assets.
  3. Within that folder, create an index.html file and paste in the following contents.
<!DOCTYPE html>
<html dir="ltr" lang="en">
  <head>
    <meta charset="utf-8" />
    <title>My App</title>
    <meta
      name="viewport"
      content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
    />
  </head>
  <body>
    <p>Hello World!</p>
  </body>
</html>

Instantiate a Web Frame

For this example, we’ll replace the “Hello World” view in the SwiftUI example template with a Web Frame that will render our index.html.

  1. In XCode, open ContentView.swift. This is your app’s root view.
  2. Add a frame property to the struct.
  3. Replace the example view in the body property with frame.view.
  4. Provide the frame property in the preview provider.
import SwiftUI
import Peregrine

struct ContentView: View {
    let frame: WebFrame

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
        }
        .padding()
        frame.view
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
        ContentView(frame: WebFrame(configuration: WebFrame.Configuration()))
    }
}
  1. Next, open *App.swift. This is your app’s entry point. It should be decorated with @main.
  2. Add an initializer that creates a Web Frame. Be sure to configure baseURL to point to the www folder in your bundle.
  3. Finally, pass the Web Frame into your ContentView.
import SwiftUI
import Peregrine

@main
struct MyApp: App {
    let frame: WebFrame

    init() {
        let baseURL = Bundle.main.url(forResource: "www", withExtension: nil)!
        let configuration = WebFrame.Configuration(baseURL: baseURL)
        frame = WebFrame(configuration: configuration)
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
            ContentView(frame: frame)
        }
    }
}

Run the app

🚧

Under Construction

Add native functionality

Let’s add some native functionality to our iOS app that we can invoke from our web content.

  1. In XCode, open *App.swift.
  1. Add a ping function that expects the string 'ping' to be sent as the request data—if so, 'pong' is sent back; otherwise, the request fails with a message and an error code.
import SwiftUI
import Peregrine

func ping(call: Call) {
    if call.request.text == "ping" {
        call.respond(with: "pong")
    } else {
        call.fail("Expected 'ping' in request.", code: "EXPECTED_PING")
    }
}

@main
struct MyApp: App {
  1. Add the functions option to the Web Frame configuration and pass in the ping function under the 'ping' key. This key will be used by the web client. Read more about remote interfaces.
    init() {
        let baseURL = Bundle.main.url(forResource: "www", withExtension: nil)!
        let configuration = WebFrame.Configuration(baseURL: baseURL)
        let configuration = WebFrame.Configuration(
            baseURL: baseURL,
            functions: [
                "ping": ping,
            ]
        )
        frame = WebFrame(configuration: configuration)
    }
  1. Open index.html.
  2. Add a script in <head> that imports the ProxyClient from the Peregrine web library and connect it to the window instance..
  3. Add a button to the <body> with an ID of pingButton.
  4. Attach a click event listener to the button by its ID. When clicked, we’ll call the ping function and print the response to the screen. If the request is unsuccessful, we’ll log the error message and code.
      content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
    />
    <script type="module">
      import { ProxyClient } from 'https://unpkg.com/@peregrine/web'
      const client = new ProxyClient()
      client.connect(window)
      const btn = document.getElementById('pingButton')
      btn.addEventListener('click', async () => {
          try {
              const response = await client.ping('ping')
              document.body.append(response, document.createElement('p'))
          } catch (error) {
              console.error(`${error.code}: ${error.message}`)
          }
      })
    </script>
  </head>
  <body>
    <p>Hello World!</p>
    <button id="pingButton">Ping!</button>
    <br />
  </body>
</html>

When we run the app again, we’ll see our button. Whenever the button is tapped, “pong” will be appended to the web view.

Congrats! 🎁

You’ve just built your first bit of cross-platform interaction powered by Peregrine. From here you have a few options:

  • Try some debugging by adding breakpoints in XCode and Safari.
  • Change 'ping' to another string to see the error message and code we’ve defined.
  • Change .ping() to an unknown function name to see what happens.
  • Read about the architecture of Peregrine to learn more.
  • Continue on to learn two other main features of the Web Frame: events and path handlers.

Add native events

Now that we’ve seen how we can implement native functionality, let’s add native events that propagate to the web layer. We’ll be implementing a new function that will initiate a mock download and report status events to the web layer.

We’ll be using the Combine framework to asynchronously send native events to the web layer. This official library powers reactive state in SwiftUI views and is a modern and declarative way to process values over time.

Combine doesn’t need to be installed—it is installed by default in SwiftUI apps.

  1. In XCode, open *App.swift.
  2. Create a CurrentValueSubject which will report status events.
  3. Create a startDownload function that imitates downloading a file and sends progress events via the status subject.
  4. Pass in the startDownload function to the functions dictionary under the 'startDownload' key.
  5. Add the observables option to the Web Frame configuration and pass in the status publisher under the 'downloadStatus$' key, first converting it to an AnyPublisher.
import Combine
import SwiftUI
import Peregrine

func ping(call: Call) {
    if call.request.text == "ping" {
        call.respond(with: "pong")
    } else {
        call.fail("Expected 'ping' in request.", code: "EXPECTED_PING")
    }
}

let status = CurrentValueSubject<Event?, Never>(nil)

func startDownload(call: Call) {
    var progress = 0
    call.respond()
    status.value = Event("pending")
    DispatchQueue.main.async {
        Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
            if progress >= 100 {
                status.value = Event("progress: 100%")
                status.value = Event("completed")
                timer.invalidate()
            } else {
                status.value = Event("progress: \(progress)%")
                progress += 25
            }
        }
    }
}

@main
struct MyApp: App {
    let frame: WebFrame

    init() {
        let baseURL = Bundle.main.url(forResource: "www", withExtension: nil)!
        let configuration = WebFrame.Configuration(
            baseURL: baseURL,
            functions: [
                "ping": ping,
                "startDownload": startDownload,
            ],
            observables: [
                "downloadStatus$": status.eraseToAnyPublisher(),
            ]
        )
  1. Open index.html.
  2. Add a button next to the ping button with the ID of downloadButton.
  3. Attach a click event listener to the button by its ID. When clicked, we’ll call the startDownload function to initiate the mock download.
  4. Create a listenForStatus function which asynchronously iterates over status events from the downloadStatus$ observable and prints them to the screen.
  5. Call listenForStatus immediately to begin listening.
            }
        })

        const downloadBtn = document.getElementById('downloadButton')
        downloadBtn.addEventListener('click', () => {
            client.startDownload()
        })

        const listenForStatus = async () => {
            for await (const status of client.downloadStatus$) {
                document.body.append(status, document.createElement('p'))
            }
        }

        listenForStatus()
    </script>
  </head>
  <body>
    <p>Hello World!</p>
    <button id="pingButton">Ping!</button>
    <button id="downloadButton">Download</button>
    <br />
  </body>

When we run the app again, we’ll see our new download button. When the button is tapped, the mock download statuses will be appended to the web view.

Add path handlers

🚧

Under Construction