A lot of excitement has swept through the Rails world since the release of Turbo in December 2020. Personally, what I’m looking forward to most is a seamless integration with native mobile platforms using the iOS and Android adapters.

We are going to explore this functionality by building a simple app for sharing YouTube videos to a list, natively from the iOS YouTube or Safari apps via the respective share button. For this endeavor we will need:

  • a Rails app containing the server-side logic for storing videos and providing the Turbo behavior,
  • a Turbo-enabled iOS app, for now only serving as a container for
  • an iOS share extension pertaining to that app

In this blog post I’m going to just outline how to assemble those building blocks, and will follow it up with a more detailed exploration later. Let’s start with the

1. Baseline Turbo Rails Harness

Thanks to railsnew.io, setting up a tailored Rails app is a matter of clicking a few check boxes and waiting a couple of seconds. This command installs the bare minimum for building a prototype, along with a TailwindCSS boilerplate setup:

$ rails new turbo_native_sharing --skip-action-mailbox --skip-action-mailer --skip-action-text --skip-active-storage --skip-spring --skip-turbolinks --template https://www.railsbytes.com/script/XbBsG6

Of course we need to add hotwire and install it:

$ bundle add hotwire-rails
$ bin/rails hotwire:install

This will install the necessary dependencies and update your ActionCable configuration accordingly. To keep things very basic, we’re just adding one model as a scaffold, the Video containing a url. We will use OEmbed to fetch embed codes for displaying the videos, obtained from YouTube by passing the raw video url.

$ bin/rails g scaffold Video url:string

The controller action for adding a video is pretty straightforward, the only thing I’ve added so far is a format.turbo_stream call to re-render a form based on validation errors.

# app/controllers/

class VideosController < ApplicationController

  # ...

  # POST /videos
  def create
    @video = Video.new(video_params)

    respond_to do |format|
      if @video.save
        format.html { redirect_to videos_path, notice: 'Video was successfully created.' }
      else
        format.turbo_stream { render turbo_stream: turbo_stream.replace(@video, partial: "videos/form", locals: {video: @video}) }
        format.html { render :new }
      end
    end
  end

  # ...
end

In the corresponding index view, I’ve wrapped the video list in a turbo_frame_tag. More or less, I’ve set up a turbo stream named videos that will receive updates from the Video model. Note the id of videos_inner on the inner grid setup, we are going to need that below.

<!-- app/views/videos/index.html.erb -->
<p id="notice"><%= notice %></p>

<div class="mb-4">
  <%= turbo_frame_tag "video_form" do %>
    <%= render "form", video: @video %>
  <% end %>
</div>

<%= turbo_stream_from "videos" %>

<%= turbo_frame_tag "videos" do %>
  <div class="grid grid-cols-1 gap-4 sm:grid-cols-2" id="videos_inner">
    <%= render @videos %>
  </div>
<% end %>

<!-- app/views/videos/_video.html.erb -->
<%= turbo_frame_tag video do %>
  <div class="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-200 border border-gray-300">
    <div class="px-4 py-5 sm:p-6">
      <%= video.embed_code %>
    </div>
  </div>
<% end %>

Side note: since <turbo-frame> inherits from HTMLElement, it’s not a block element by default, so you’ll have to add turbo-frame { display: block; } to your stylesheet.

The Video model does some magic in an after_find callback to fetch the embed code from YouTube’s OEmbed endpoint. Apart from that, it broadcasts changes to the videos stream, and inserts them by prepending them before the existing ones. It took me a while to find out that you can specify a target for this prepend action, which is the ID (videos_inner) we gave to the grid container in the markup above.

class Video < ApplicationRecord
  validates :url, presence: true

  after_find :fetch_oembed

  after_save_commit do
    fetch_oembed
  end

  broadcasts_to ->(video) { :videos }, inserts_by: :prepend, target: "videos_inner"

  def fetch_oembed
    @oembed_resp = JSON.parse Faraday.get("https://www.youtube.com/oembed", {url: url, format: :json, maxheight: 100}).body
  end

  def embed_code
    @oembed_resp["html"].html_safe
  end

  def title
    @oembed_resp["title"]
  end
end

As it stands, we can add videos to the list by entering them in an input field:

../assets/images/posts/2021/2021-02-18-turbo-native-ios-01-rails-app.gif

In the logs, we can see that turbo issues a prepend action to insert the posted video:

Started GET "/videos" for ::1 at 2021-02-18 11:25:48 +0100
Processing by VideosController#index as TURBO_STREAM
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] [e0ef6a50-2629-4761-8152-ff25294d91b1]   Rendered videos/_video.html.erb (Duration: 1.2ms | Allocations: 383)
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] [e0ef6a50-2629-4761-8152-ff25294d91b1]
[ActionCable] Broadcasting to videos: "<turbo-stream action=\"prepend\" target=\"videos_inner\"><template><turbo-frame id=\"video_77\">\n  <div class=\"bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-200 border border-gray-300\">\n    <div class=\"px-4 py-5 sm:p-6\">\n      <iframe width=\"200\" height=\"113\" src=\"https://www.youtube.com/embed/8bZh5LMaSmE?feature=oembed\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe>\n    </div>\n  </div>\n</turbo-frame></template></turbo-stream>"

2. Test iOS App

Create a native iOS project in XCode using the default App template. Be sure to select “Lifecycle” under “Interface” and “UIKit App Delegate” from the Life Cycle menu in order to build out the Turbo app later.

../assets/images/posts/2021/2021-02-18-turbo-native-ios-02-new-project.png

Next, in the project settings go to the Swift Packages tab and add the Turbo iOS dependency by entering in https://github.com/hotwired/turbo-ios.

../assets/images/posts/2021/2021-02-18-turbo-native-ios-03-project-settings.png

../assets/images/posts/2021/2021-02-18-turbo-native-ios-04-add-package.png

Check out the Turbo-iOS quickstart guide to get a more detailed walkthrough of building the boilerplate for a Turbo-iOS app: https://github.com/hotwired/turbo-ios/blob/main/Docs/QuickStartGuide.md

3. Share Extension

To create a new share extension, select “New…” -> “Target” from the XCode “File” menu. You will be queried for a target template, so choose “Share Extension”, and give it a name:

../assets/images/posts/2021/2021-02-18-turbo-native-ios-06-share-extension.png ../assets/images/posts/2021/2021-02-18-turbo-native-ios-07-share-extension-2.png

Finally, edit the share extension’s Info.plist to enumerate the supported content types:

../assets/images/posts/2021/2021-02-18-turbo-native-ios-08-supported-types.png

That’s NSExtensionActivationSupportsWebPageWithMaxCount with a Number of 1, glad you asked. This will allow the extension to share exactly one web page (e.g. from mobile Safari).

Now, before we move on, it’s important to understand that the containing application and the extension do not communicate or share any data with one another. There is a way via App Groups and shared containers, but we will not need that for this very primitive proof of concept.

To add more confusion to the mix, the developer guides frequently mention a host app, which is not the containing app. The host app is the app your share extension is called from, i.e. Mobile Safari in our case.

If you’d like to know more up front, take a look at the Handling Common Scenarios part of the documentation archive. Sadly, no more up to date document seems to be available.

Now find the ShareViewController.swift and write the didSelectPost callback:

import UIKit
import Social
import MobileCoreServices
import WebKit

class ShareViewController: SLComposeServiceViewController {
    override func didSelectPost() {
        // This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments.

        let attachments = (self.extensionContext?.inputItems.first as? NSExtensionItem)?.attachments ?? []
        let contentTypeURL = kUTTypeURL as String

        for provider in attachments {
            // Check if the content type is the same as we expected
            if provider.hasItemConformingToTypeIdentifier(contentTypeURL) {
                provider.loadItem(forTypeIdentifier: contentTypeURL, options: nil, completionHandler: { (results, error) in
                                                                                       let url = results as! URL?
                                                                                       self.save(url: url!.absoluteString)
                                                                                   })
            }
        }

        // Inform the host that we're done, so it un-blocks its UI. Note: Alternatively you could call super's -didSelectPost, which will similarly complete the extension context.
        self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
    }
}

Let it be said that I built this by following a few blog posts, and a lot of trial-and-error. We are essentially querying the extensionContext for the first input item’s attachment, which contains our URL. This is wrapped in a provider though, so we can sanitize it against the correct content type in our case kUTTypeUrl from CoreServices. Once we are sure that the attachment has the correct type, we can load it and in the completionHandler, save it to our application. Now, here comes the save method, which is nothing else than a glorified form POST request:

private func save(url: String) {
    let configuration = URLSessionConfiguration.default
    let session = URLSession(configuration: configuration)
  
    let rootUrl = URL(string: "http://localhost:3000/videos")
  
    var request : URLRequest = URLRequest(url: rootUrl!)
    request.httpMethod = "POST"
    request.httpBody = "video[url]=\(url)".data(using: String.Encoding.utf8)
  
    let dataTask = session.dataTask(with: request) { data,response,error in
        guard let httpResponse = response as? HTTPURLResponse, let receivedData = data
        else {
            print("error: not a valid http response")
            return
        }
        switch (httpResponse.statusCode) {
        case 200: //success response.
            break
        case 400:
            break
        default:
            break
        }
    }
    dataTask.resume()
}

We equip a URLRequest with our form data (simply the video[url]= format Rails expects) and send it via a dataTask. You’d be forgiven if you think that this should not work out of the box, because that is correct. Another crucial part of a Rails form request is missing, the CSRF token! So to make this work, let’s pretend we are otherwise authenticated and are actually issuing an API POST, so let’s skip the forgery protection 😁:

class VideosController < ApplicationController
  skip_before_action :verify_authenticity_token
  
  # ...
end

And running this little share extension in our simulator, we can post URLs to our Rails app:

../assets/images/posts/2021/2021-02-18-turbo-native-ios-09-complete.gif

4. Conclusion

I’m not going to lie to you, this is in no way a complete solution for sharing items between a mobile iOS app and a Rails app. However, little information and/or knowledge exists about how to bridge the gap between those two worlds. Yes, Turbo-iOS is a fantastic piece of software, and we’ll see what Strada brings to this picture. Basecamp’s famous hybrid sweet spot blog post showed the way how to do achieve a lot with minimal effort, but there’s still a lot for the solo developer to grok. One example is the elephant in the room, cross-platform authentication, which I haven’t even touched upon, because as yet I have no idea how to do it. The authentication guide talks about persisting an OAuth token in keychain and using that to authenticate requests from the app.

This post here just tries to showcase what’s possible, and will be followed up by more detailed discussions about authentication and communication between share extension and containing app. Stay tuned!