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 fromHTMLElement
, it’s not a block element by default, so you’ll have to addturbo-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:
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.
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.
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:
Finally, edit the share extension’s Info.plist
to enumerate the supported content types:
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:
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!