Ludovic Frank - Freelance developer

From zero to production: put your Symfony-UX/Turbo application on the App Store (iOS)

ionicons-v5-k Ludovic Frank Sep 13, 2023
143 reads Level: Confirmed

Hi there 🙂,
After last week's slightly lighter article, this week we're going into something a bit harder....

My experience with native and hybrid apps (before Turbo Native)

This isn't my first app on the App Store, whether it's personal projects or client apps, I've already had the opportunity to work on iOS, through Cordova at the time or even React Native, but I've put that part to one side for a while...

100% native applications

In the case of iOS, when you want to work natively, you need to use the XCode IDE, and the language of choice, in 2023, is Swift.
In fact, I've got nothing against these environments, they're great fun to use, and Apple goes to great lengths to make developers want to create on their platform, that's a fact.
But I've never delved any deeper into this environment...

Why not?
Swift can only be used for one thing (even if you can run it on servers), and that's to work in an ecosystem, Apple's ecosystem.From my experience, application validation on the App Store (side loading is not an option in the pro world) is very strict.

Apple has very strict rules, and if you want to exist on the iPhone, you have to abide by them, that's all.

That's why I didn't want to invest time in skills that I can only use under Apple's giro, preferring to deepen my knowledge in technologies that allow me to offer services to more people...

Web applications (packaged with Cordova or Capacitor)

I've spent a lot of time on these technologies, with this application for example, which runs on the Ionic Framework (Angular).

Just like "100% native", I've put this technology aside... it's been almost 4 years since I wrote any code for an app that would end up on the App Store.

Why is that?
Basically, the "webview" does everything, there are plugins, which are written in native code, but as soon as you want to do something a little more advanced, you get the impression that you're tinkering with something... for example, I remember an application in which the user could easily log in by scanning a QR code displayed on his computer... And to display what the camera on the computer was looking for, the user had to write a code.
And to display what the phone's camera was filming, I had to make the background of my (HTML) view transparent, so that the "native view" was displayed.

This is just one example among many, but every time I do something like this I think "Ah yes, that's some high-level tinkering there anyway..."

There's another point that bogs me down: why use the web to make transitions? iOS has its own navigation controller, and it works perfectly, it handles transitions and gives access to gestures that users are used to using, wouldn't it be nice to use them?
I later saw that others had the same idea, and that we were starting to see plugins that could replace Ionic's CSS transitions with that of the OS, but at the time I wasn't interested. I wasn't really interested in this at the time, as Cordova was slowly being phased out and replaced by "Capacitor".

React Native

In fact, in the case of React Native, I criticize it for the same thing I criticize React for: it's a technology, like native, that requires a huge investment, and we see a lot of companies chasing after "React" and "React Native" developer profiles, because they don't have the same skills.React Native" developer profiles, because they started working with these technologies (because that's what developers wanted at the time) and are now stuck in a complex ecosystem, with few profiles on the market (the laws of supply and demand, all that).
In fact, on a freelance mission, I helped out a company that was in this situation (I didn't say I didn't know React, just that when I can avoid it, I avoid it).

On that note, let's get back to Turbo Native, if you don't mind 😁

Which application are we talking about?

As always, my "pilot" app used in production on Symfony-UX/Turbo, and at the time of writing, it's well distributed on the App Store.

Its arrival on the App Store was planned from the start...

In fact, when I was working on this application last year, I was already anticipating its arrival on the App Store, when I was researching "hotwired Turbo", I saw that"Strada (bottom of page)" was under development at the time, even though beta versions were available, I just kept the info in a corner of my mind.

My thoughts before the release of Turbo Native ("Strada")

In fact, at the time, I hadn't even looked at the work done by the Turbo team on "Strada", I only had my knowledge of what Apple expects of you when you want to publish an application on the App Store.

Turbo and transitions

I really like Turbo, but there's one thing that bothers me, and that's that when you click on something, or validate a form, there's a moment of "floating", you get the impression that the application is blocked, even if it's less than 40ms, there's no "satisfaction" that the action has been taken into account.

So, on Vélo en France, I came up with my version of Turbo, a version that, whatever happens, gives the user information on the state of the application.you'll always get a "spinner" informing you that "it's loading", so even if your connection isn't crazy, you'll always have a feeling of fluidity.

On the reservation application, there's also this "spinner", but it's not that advanced, I haven't directly modified Turbo to do it, I use "Turbo Frame" and that's going to be a problem for me later on.

And why am I taking so much trouble with these transitions?
It's very simple: try to put an application without a clean transition on the App Store, and you'll simply be rejected...
"We don't want a website, we want an app."

The thing is, I anticipated a problem I wasn't the only one working on, in the case of "Turbo Native" on iOS the transitions are managed... we'll come back to that later 😁.

Latency

Unlike a "classic" application where the server just sends back JSON, and any graphics are handled by the client side, "Turbo" assumes that we're using the "templating" engine, which is on the server side...

So what?
When it comes to checking the application, Apple's teams are located... in California, and the servers that "create" the views? In France.

So, to sum up, when they test the app, their requests cross the United States from east to west and um... an ocean 🤣, latency-wise, we're good there aren't we?

Control what you can control ...

The thing is, the network, I don't have a hand on it, I control the client and the server, what happens between, apart from my eyes to cry, I've got nothing.

On the server side, you need to respond as quickly as possible, and for that you need optimized code. We've got PHP that takes full advantage of OPCache, Redis and that kind of technology, or... "Varnish" 🙂.

On the client side, Turbo already has a snapshot system... what if we modified it so that when a user launches the application, we already cache certain things? 😮

These are basically the things I had to think about, before seeing the work done by the Hotwired team on "Turbo native". 🙂

The "Turbo Native" release

Here we are, Strada is here, and this tweet officially announces it. Looking at the github page, we see release 7.0.0.

The discovery

Well, let's have a look at it all. Looking at the doc, it says you have to create an "xcode" project, which means you have total control over the native code...

After an hour of exploring all this, a message on What's app is gone 😮

 

As you'd expect, the answer was yes.

Preparing your Symfony-UX/Turbo application

Exposing Turbo to the iOS app

First, we need to enable the iOS application to communicate with Turbo:

1 2
import * as Turbo from '@hotwired/turbo';
window.Turbo = Turbo

Yes, ultra-simple, we make Turbo accessible in the "window".

Detect when the client is the iOS application

To do this, we'll create a service in Symfony that checks the user agent for traces of Turbo Native:

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
<?php

namespace App\Tools;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;

class TurboNativeTools
{
    private string $userAgent;

    public function __construct(RequestStack $requestStack)
    {
        if ($requestStack->getMainRequest() ){
            $this->userAgent = $requestStack->getMainRequest()->headers->get('User-Agent');
        } else {
            $this->userAgent = "";
        }
    }

    public function iSTurboNative(): bool
    {
        if (str_contains($this->userAgent, 'Turbo Native')) {
            return true;
        }
        return false;
    }

    public function isTurboNativeIOS(): bool
    {
        if (str_contains($this->userAgent, 'Turbo Native iOS')) {
            return true;
        }
        return false;
    }

    public function isTurboNativeAndroid(): bool
    {
        if (str_contains($this->userAgent, 'Turbo Native Android')) {
            return true;
        }
        return false;
    }
}

You should also create a Twig function that uses, to know when the client is the iOS app.

Also think about the front end, pretty much the same code, but in JavaScript:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
export class TurboNativeDetector {
    static _getUserAgent() {
        return navigator.userAgent
    }

    static isTurboNative() {
        return TurboNativeDetector._getUserAgent().includes('Turbo Native');
    }

    static isTurboNativeIOS() {
        return TurboNativeDetector._getUserAgent().includes('Turbo Native iOS');
    }

    static isTurboNativeAndroid() {
        return TurboNativeDetector._getUserAgent().includes('Turbo Native Android');
    }
}

By default, Turbo Native doesn't add anything to the user as an agent; we'll do that in the "case study" chapter of this article, below.

Conquering iOS

Come on, let's get to work.

Apple, I'd like to learn the basics of your universe for devs.

Remember above? When I told you about "100% native" applications?
I told you I didn't want to spend time on technologies that only work in the Apple ecosystem, but this is different, I don't need to know how to interface with Apple's proprietary "view" system, in fact I just need to understand a few basics, which took me a total of 30 hours.

The view controller

So, if you want to get started, I'm going to save you some time. You'll need this project: "Turbo navigator ".
To put it simply, "Turbo navigator" includes the code you should have in all your "Turbo Native" applications. Use it, modify it, adapt it; in fact, you can see a "simplified" version in the demo application.

WKWebViews and Sessions

WKWebviews are interface components for native iOS applications. As their names suggest, they are "views" using the Safari engine, but beware: this is not Safari. You'll have to rewrite it 😮 we'll look at this type in more detail below.

Shared pools

What you need to understand here is that, unlike a Cordova application, you don't necessarily have just one webview. For example, when you want to open a modal, you'll load a second webview. But above all, they each have their own cookies, local storage... and so on.

To overcome this, we use a "WKProcessPool", which allows us to share cookies and local storage between different views.

Communication between native code and the web

In some cases, you'll need to send information that will be processed by the native code. To do this, WKWebviews expose JavaScript-aware functions:

1
webkit.messageHandlers.nativeApp.postMessage(message)

note here that "nativeApp" is a variable you choose in the native code.

From the native code, you can communicate with the JavaScript code by using the function :

1
session.webView.evaluateJavaScript("maFonctionJs()")

As you can see, we're actually "injecting" JavaScript, just as if we were doing it from the browser console.

Summary

I won't go into too much detail here, simply because in the next chapter, we'll be taking real-life cases, with real code snippets used in production, so I just want to give you the basics: In addition to that, you can take a look at the project on Github and its documentation, but above all at Joe Masilotti's blog, who is the main contributor to "Turbo Native". On his blog, you'll find lots of resources to take you further.

Now, let's take a look at some concrete cases...

Case study

In the previous paragraph, we skimmed over the things you need to know, so there's no point going into detail about things that are already well documented elsewhere.As far as possible, read these chapters in order, because, as you'll see, some things come up again and again, and I won't go into detail every time.

Modifying the Webview user agent

As we mentioned earlier, on the server side (and in JavaScript) we need to detect when the client application is Turbo Native iOS.These three words will tell us whether Turbo Native is being used, and if so, whether it's the iOS version.

To do this, it's in the native code, but you're in luck, it's already done in Turbo Navigator (I told you it was a must-have 😛).

Take a look at this file: at the top, we see the definition of the "string" we're going to add to the "user agent".

Then, further down: "configuration.applicationNameForUserAgent = userAgent".

The "WKWebViewConfiguration" object will be passed to the "WKWebview", which will then add "Turbo Native iOS" to the user agent used for each request.

And you'll see, we use it "all the time".

Open Apple map

Since we're on iOS, when the user wants to know an itinerary (in our case, going to a restaurant) we'll prefer to open the Apple map application, which is on all iPhones.

In the iOS documentation, it is stated that every link passed to the bone containing "https://maps.apple.com/" will be opened in the map application.
So, you're probably thinking "well, all we have to do is make a link in HTML to plan, yes, but no, remember, we're in "WKWebView" not in "Safari", if we do that then it will open the link in the "embedded Safari" offered by iOS, and that's not what we want, we want plan to open directly.

Let's start with our "twig" view;

1 2 3 4 5 6 7 8 9 10 11 12
                            {# Voilà pourquoi il fut exposer le service à twig  #}
{% if turbo_native_tools.turboNativeIOS %}
        <div class="text-center mb-3">
            <a data-turbo="false" 
               target="_blank" 
               class="btn btn-success"
               href="https://maps.apple.com/?daddr={{ (restaurant.address ~ " " ~ restaurant.zipCode ~ " " ~ restaurant.city)|url_encode }}"
            >
                {{ 'ludodev.tools.launch_gps'|trans }}
            </a>
        </div>
{% endif %}

As you can see, here it looks like a simple link, but in fact it's not, this link will be intercepted, it's also code found in "Turbo Navigator", more precisely here

As a result, you need to modify the function, detect whether you want to go to Apple Plan and, if so, pass it directly to iOS.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// le code avant

func openExternalURL(_ url: URL, from controller: UIViewController) {
        if url.host == "maps.apple.com" {
            // Open with Apple Maps directly
            UIApplication.shared.open(url, options: [:], completionHandler: nil)
        } else {
            // Open with SFSafariViewController
            let safariViewController = SFSafariViewController(url: url)
            safariViewController.modalPresentationStyle = .pageSheet
            if #available(iOS 15.0, *) {
                safariViewController.preferredControlTintColor = .tintColor
            }
            controller.present(safariViewController, animated: true)
        }
    }

Here you can see the original Turbo Navigator function, with just an if in front of it. If map is detected, the link is sent directly to iOS.

We could use a Stimulus controller and push code from the webvieww to the native code for this, but that would complicate things...

You can see how it works on this video (Youtube)

Allow the user to call a number

Moving on, it's very similar to what we've seen before, but this time it's going to be a little more complicated. Indeed, if you try to put a "tel" link in your application, this time it'll crash your application altogether, as WKWebview only supports HTTP and HTTPS links by default.

In our "twig" view:

1 2 3 4 5 6 7 8 9 10 11
<a {{ stimulus_controller('tel-link', {
    number: restaurant.phoneForLinks
}) }}
        {{ stimulus_action('tel-link', 'click') }}
        href="tel:{{ restaurant.phoneForLinks }}" data-turbo="false"
        class="text-decoration-none"
>
    {% endif %}
    {{ restaurant.phoneHumanReadable }}
    {% if restaurant.phoneForLinks is not empty %}
</a>

As you can see here, we call up a Stimulus controller, and intercept the "click" on our link, here's the "Stimulus" controller:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
import {Controller} from '@hotwired/stimulus'
import {TurboNativeDetector} from "../tools/turbo-native-tools";

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    static values = {
        number: String
    };

    click(event) {
        if (TurboNativeDetector.isTurboNativeIOS()) {
            event.preventDefault();
            webkit.messageHandlers.nativeApp.postMessage({
                "action": "tel",
                "number": this.numberValue,
            })
        }
    }
}

Here we use the class I showed you earlier to detect whether we're in a native Turbo application or not.
If we are, then we call the function exposed by "WKWebView", "webkit.messageHandlers.*.postMessage", enabling us to send information to the native code.

Now, on the native side, we'll have to manage this:

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
import WebKit

public class TurboConfig {
    // Ce fichier est visible dans Turbo Navigator, je le reprends pour vous simplifier la compréhension

    // Blablabla

    private func makeWebViewConfiguration() -> WKWebViewConfiguration {
        let configuration = WKWebViewConfiguration()
        configuration.applicationNameForUserAgent = userAgent
        configuration.processPool = sharedProcessPool
        let messageHandler = CustomWkWebViewHandler()
        configuration.userContentController.add(messageHandler, name: "nativeApp") // c'est ici qu'on définit le "nativeApp" appelé depuis le javascript, et ici on dit a swift quell class doit écouter ce "nativeApp"
        return configuration
    }

    // blablabla

}

// Normalement dans un fichier différent, mais pour simplifier votre compréhension...
// Cette classe est là pour écouter les messages envoyer par la WKWebView
public class CustomWkWebViewHandler: WKScriptMessageHandler {
    // Cette fonction est celle 
    public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if let data = message.body as? [String: Any] {
            let action = data["action"] as? String // Vous reconaissez cette "action" ? on la créer dans le controller Stimulus, coté web
        
            switch action {
            case "share":
                nativeShare(data: data)
            // D'autres actions ... 
            default:
                // Handle other actions or ignore
                break
            }
        }
    }

    // Le code natif qui affiche la petite "dialog" qui permet d'appeler
    func nativeTel(data: [String: Any]) {
        let number = data["number"] as? String ?? ""
        // Attention, pour éviter le crash, il faudra ajouter Queried URL Schemes -> tel dans votre info.plist, sinon... CRASH
        if let url = URL(string: "tel:" + number), UIApplication.shared.canOpenURL(url) {
            UIApplication.shared.open(url)
        }
    }
}

In fact, in Swift, we have parent classes designed to listen for events. Here, our parent class is "WKScriptMessageHandler".

As above, click here to see the video (on YouTube)

In an application, if you're observant you'll have noticed that there are animations between each "view", plus there's a whole system of gestures that platform users, for example, swipe from left to right to go back (as if removing the last view from the grid).

Basically, Tuurbo Native simply follows the links that are managed by the JavaScript, after which, in the "basic" version, it's rather limited, so I quickly switched to Turbo Navigator, which handles this really well. when JavaScript makes a "visit", the iOS navigation controller adds a new view to the stack, unless it's in a "turbo frame".

Third-party libraries

It's funny, I was already talking about this in my first article on Symfony-UX/Turbo, but with Stripe I had another little problem...

In fact, Stripe element, once the "setupIntent" creation process is complete, will use a "window.location" to load the "destination page", and that in turbo native by default would open Safari and, if you use the Turbo Navigator overlay, it would open an embedded Safari "in app".
So the best thing to do is tell Stripe not to reload the page, and do a "Turbo.visit" ourselves:

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
import {Controller} from '@hotwired/stimulus'
import {loadStripe} from '@stripe/stripe-js';
import * as Turbo from '@hotwired/turbo';


export default class extends Controller {
    
    /// autres méthodes
    
    // Quand le formulaire de carte bancaire est envoyé 
    async formSubmit(event) {
        event.preventDefault();

        const elements = this.elements;
        const completeURL = this.completeUrlValue;

        // Très important, on ajoute un "if required" au redirect, pour empêcher Stripe Element de faire un window.location
        const {error} = await this.stripe.confirmSetup({
            elements,
            redirect: 'if_required',
            confirmParams: {
                return_url: completeURL,
            }
        });

        if (error) {
            // On gère l'erreur
        } else {
            Turbo.visit(this.completePathValue + '?setup_intent=' + this.stripeSetupIntentIdValue,{});
        }
    }
}

And there's no problem, well, I'm using Stripe as an example here, but in fact, in the booking app, Stripe isn't a traditional "navigation stack".

During development, I had a problem: in the reservation web app, I already had my method for managing transitions. Well, in the web there's no "native" code to manage that, so it's up to us to manage it.

The problem was that my code to manage this was compatible with a classic navigation stack... In fact, even adapting my web code, the fact of having the "back" button in the order-taking wizard made for a completely lousy user experience.

To understand what I'm talking about, you can watch this video on YouTube.

On the video, you can see that when I "tap" on the button to reserve, rather than navigating "forward" and changing the whole window, a simple window (a modal) comes on top. In fact, this part is simply managed by Turbo Navigator, which needs to be supplied with a .json file.

1 2 3 4 5 6 7 8 9 10 11 12 13 14
{
  "settings": [],
  "rules": [
    {
        "patterns": [
          "reservation"
        ],
        "properties": {
          "presentation": "replace",
          "context": "modal"
        }
    }
  ]
}

A relatively simple file, with patterns and instructions for these patterns, which you can read more about in the documentation.
Once this file has been created, you can either host it on your server, or, as I've done for the moment, package it in your application. Once your file is in your "XCode" project, add it to your "SceneDelegate":

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
import UIKit
import Turbo
import WebKit
import SafariServices
import TurboNavigator

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    // La configuration du Path, on y indique notre fichier
    private lazy var pathConfiguration = PathConfiguration(sources: [
        .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!)
    ])
    
    // On le passe a TurboNavigator
    private lazy var turboNavigator = TurboNavigator(delegate: self, pathConfiguration: pathConfiguration, navigationController: self.navigationController, modalNavigationController: self.modalNavigationController)

    // le reste de la classe
}

All that's left is to adapt your file according to the documentation, so that it works the way you want it to.

Native sharing

Ah, reminiscent of one of the first articles I wrote on this blog...
Except that, as I've said several times, this isn't Safari, and "window.navigator.share" doesn't exist, in fact as I'm writing these lines I'm thinking we could "re-create" it so we don't have to modify the existing code, but having 100% of the conrol on the application code, I've done it differently, so hang on, it's a bit more complex.

To start with, in twig, we're going to use a Stimulus controller:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18

<a 
    class="btn btn-success mt-2" 
    data-turbo="false"
    {# On appel un controller Stimulus, oui beacoup d'infos a passer :p #}
    {{ stimulus_controller('reservation-share-button', {
     'text' : 'ludodev.email.customer.client_share.subject'|trans({
         '%restaurantName%' : restaurant.name,
         '%reservationDate%': reservation.reservationDateTime|format_datetime('full', 'short', locale=app.request.locale)}) ~ "\n" ~ 'ludodev.email.customer.client_share.body'|trans ~ "\n",
     'url' :  url('app_customer_shared', {'restaurantSlug' : restaurant.slug, 'uuid': reservation.uuid})
 }) }}
{{ stimulus_action('reservation-share-button', 'onClick') }}
    
    {# Ca c'est un failback, pour les navigateurs qui sont ni natif et qui ne supporte pas window.navigator.share #} 
    href="mainto:un@email.com?subject=..." 
>
    {{ 'ludodev.customer.sentence.share_reservation_with_guests'|trans }}
</a>

Yeah! we pass it a lot of info, but it's just a lot of data, it's nothing.

Next, we have a classic Stimulus controller:

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
import {Controller} from '@hotwired/stimulus'
import {TurboNativeDetector} from "../tools/turbo-native-tools";

export default class extends Controller {
    static values = {
        text: String,
        url: String,
    }

    onClick(event) {
        if (TurboNativeDetector.isTurboNativeIOS()) {
            if (typeof event !== 'undefined') {
                event.preventDefault();
                webkit.messageHandlers.nativeApp.postMessage({
                    "action": "share",
                    "text": this.textValue,
                    "url": this.urlValue,
                    "modalController": true // tient, c'est quoi ça ? :p
                })
            }
        } else if (window.navigator.share) { 
            // On gère l'evenement avec window.navigator.share
        }
    }
}

Here you'll recognize the little "...nativeApp.postMessage", passing the data to the native code.
We also pass a "modalController" variable, which tells the native code whether our controller (the iOS navigation controller) is the "basic" one or the "modal" one.

Important for what follows: as you can see in "Turbo Navigator", there are two controllers, one for "basic" navigation and one for "modal navigation".
For the interface creations that follow, iOS asks from which controller it should create these interfaces, so our code needs to pass whether or not we're in the modal.

For simplicity's sake, I'm going to put everything in the "TurboConfig" class, but once you're used to Swift, you'll understand that we need to separate the classes:

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
import WebKit

public class TurboConfig: WKScriptMessageHandler {

    private var navigationController: UINavigationController = UINavigationController()
    private var modalNavigationController: UINavigationController = UINavigationController()
    
    // Le code de la classe

    // La méthode qui intercepte les messages de WKWebView    
    public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if let data = message.body as? [String: Any] {
            let action = data["action"] as? String
            switch action {
            case "share":
                nativeShare(data: data)
            default:
                // Handle other actions or ignore
                break
            }
        }
    }
    
    // Ici on présente la fenêtre de partage native
    func nativeShare(data: [String: Any]) {
        let text = data["text"] as? String
        let url = data["url"] as? String
        let openInModalController = data["modalController"] as? Bool ?? true
        
        if let text = text, let urlStr = url, let urlObj = URL(string: urlStr) {
            let items = [text, urlObj] as [Any]
            let ac = UIActivityViewController(activityItems: items, applicationActivities: nil)
            
            // et la vous comprenez a quoi sert le "modalController" dans le controller Stimulus
            if (openInModalController){
                modalNavigationController.present(ac, animated: true)
            } else {
                navigationController.present(ac, animated: true)
            }
        }
    }
}

The first difficulty here is that we need access to the controllers, which is why everything is in TurboConfig... it's up to you to clean it up 😁 but I'm assuming that, like me a week ago, you know Swift by name.

Here, I'm not putting a video, because you've already seen what the iOS sharing interface looks like in, that article, it's exactly the same interface here, except you can't call it via "window.navigator.shar"

Displaying the email dialog box

On iOS it's not uncommon to see system elements embedded in applications, for example an "embedded Safari" is quite common when you open an HTTP link in applications.

It's a little more difficult than what we've done before, because there's an extra notion to take into account.

In Twig, we're going to add a call to a Stimulus controller on "mailto" links:

1 2 3 4 5 6 7 8 9 10 11
<a {{ stimulus_controller('mail-link', {
        email: reservation.restaurant.email,
        modalController: true
    }) }}
    { stimulus_action('mail-link', 'click') }}
    href="mailto:{{ restaurant.email }}" 
    target="_blank" 
    class="text-decoration-none"
>
    {{ restaurant.email }}
</a>

Nothing too complicated, you're starting to get the hang of this kind of code 😛.
Now for the Stimulus controller:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
import {Controller} from '@hotwired/stimulus'
import {TurboNativeDetector} from "../tools/turbo-native-tools";

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    static values = {
        email: String,
        modalController: Boolean
    };

    click(event) {
        if (TurboNativeDetector.isTurboNativeIOS()) {
            event.preventDefault();
            webkit.messageHandlers.nativeApp.postMessage({
                "action": "mailto",
                "email": this.emailValue,
                "modalController": this.modalControllerValue
            })
        }
    }
}

Yes, it looks just like all the others, in fact we're just passing the data to the native code.

It's in the native code that things get a little more complicated:

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
import WebKit
import MessageUI

public class TurboConfig: NSObject, WKScriptMessageHandler, MFMailComposeViewControllerDelegate,  {
    
    // Le reste du code de la classe

    // Vous conaissez a la connaitre cette méthode d'écoute là, non ?
    public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if let data = message.body as? [String: Any] {
            let action = data["action"] as? String
            //print("\(String(describing: action))")
            
            switch action {
            case "mailto":
                nativeEmail(data: data)
            default:
                // Handle other actions or ignore
                break
            }
        }
    }
    
    // On demande a iOS d'afficher notre popup d'envoie d'email
    func nativeEmail(data: [String: Any]){
        let email = data["email"] as? String ?? ""
        if MFMailComposeViewController.canSendMail() {
            let mailComposer = MFMailComposeViewController()
            
            mailComposer.mailComposeDelegate = self
            
            // Set default values for the email fields
            mailComposer.setToRecipients([email])
            
            let openInModalController = data["modalController"] as? Bool ?? true
            
            if (openInModalController){
                modalNavigationController.present(mailComposer, animated: true, completion: nil)
            } else {
                navigationController.present(mailComposer, animated: true, completion: nil)
            }
            
        }
    }

    // Tient ça c'est nouveau non ?    
    public func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
        controller.dismiss(animated: true, completion: nil)
    }
}

What's new here is the "mailComposeController" method. In fact, this iOS popup calls this method when the user clicks on the "close" button of the email modal, so we have to manage it and simply close it.

As above, I've put everything in the "TurboConfig" class, so it's easier to understand, but once you've understood it, it's up to you to break it down into several classes, and don't forget, "MFMailComposeViewController" also needs to know the view controller from which it's called 😁.
By the way, if you're observant you've seen the NSObject extension, it's a prerequisite of this iOS function 🙂

If you want to see what it looks like, you can watch it on this little YouTube video.

Adding an event to the calendar

Frankly, at first I thought "it must be simple", the "iCal" comes from Apple, right? So there must be a native function that reads these files and basta? ... how naive I was 🤣 !

First of all, in "info.plist", you'll need to add a line to say that you plan to work with the calendar, so in your "info.plist" file, add: "NSCalendarsUsageDescription" with the value "The text presented to the visitor when access to the device's calendar is requested".

Once this is done, in our twig :

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
{% if turbo_native_tools.iSTurboNative %}
<a
    {{ stimulus_controller('native-calendar', {
        cal: calData,
        modalController: true
    }) }}
    {{ stimulus_action('native-calendar', 'click') }}
    class="btn btn-primary mt-2" 
    href="#"
>
    {{ 'ludodev.customer.sentence.add_reservation_to_my_calendar'|trans }}
</a>
{% else %}
{# On gère normalement #}
{% endif %}

There, I see you say, what's "calData"? It's a "JSON" that I generate with all the info that I normally put in the iCal file, so that you can better understand what's coming next, here's a dump :

1 2 3 4 5 6 7 8 9 10
{
    "start_date": { "date": "2023-09-17 11:15:00.000000", "timezone_type": 3, "timezone": "Europe/Paris" },
    "end_date": { "date": "2023-09-17 12:15:00.000000", "timezone_type": 3, "timezone": "Europe/Paris" },
    "summary": "Les Fr\u00e8res Marchand",
    "description": "R\u00e9servation pour 4 couverts.\n\nNotre num\u00e9ro de t\u00e9l\u00e9phone +330383328594",
    "organizer_email": "restaurant@feres-marchand.com",
    "organizer_name": "Les Fr\u00e8res Marchand",
    "url": "https://reservation.fromages-freres-marchand.fr/url-de-la-reservation",
    "location": "99 grande rue, 54000 Nancy"
}

So, as usual, our Stimulus controller:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
import {Controller} from '@hotwired/stimulus'
import {TurboNativeDetector} from "../tools/turbo-native-tools";

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    static values = {
        cal: Object,
        modalController: Boolean
    };

    click(event) {
        if (TurboNativeDetector.isTurboNativeIOS()) {
            event.preventDefault();
            webkit.messageHandlers.nativeApp.postMessage({
                "action": "add-to-calendar",
                "cal": this.calValue,
                "modalController": this.modalControllerValue
            })
        }
    }
}

So far so familiar ... now let's go native:

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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
import WebKit
import EventKitUI

public class TurboConfig: WKScriptMessageHandler, EKEventEditViewDelegate {
    public var eventStore = EKEventStore()
    
    public static let shared = TurboConfig()
    
    private var navigationController: UINavigationController = UINavigationController()
    private var modalNavigationController: UINavigationController = UINavigationController()
    
    // Le code de TurboConfig

    public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if let data = message.body as? [String: Any] {
            let action = data["action"] as? String
            //print("\(String(describing: action))")
            
            switch action {
            case "add-to-calendar":
                nativeAddToCalendar(data: data)
            default:
                // Handle other actions or ignore
                break
            }
        }
    }
    
    func nativeAddToCalendar(data: [String: Any]) {
        let calData = data["cal"] as? [String: Any] ?? [:]
        let openInModalController = data["modalController"] as? Bool ?? true
        
        // Avant même de créer l'event, on doit demander a IOS le droit d'accèder au calendrier
        switch EKEventStore.authorizationStatus(for: .event) {
        case .notDetermined:
            self.eventStore.requestAccess(to: .event) { (granted, error) in
                if granted {
                    // Vu que l'action est asynchrone, on pousse la suite dans le "thread principal"
                    DispatchQueue.main.sync {
                        self.presentEventEditViewController(calData: calData, openInModalController: openInModalController)
                    }
                }
            }
        case .authorized:
            self.presentEventEditViewController(calData: calData, openInModalController: openInModalController)
        default:
            break
        }
    }
    
    func presentEventEditViewController(calData: [String:  Any], openInModalController: Bool) {
        // On créer un evenement que l'on bind. a l'evenstore, a ce moment là l'evenstore est autorisé a accéder au calendrier de l'utilisateur (sinon ce code ne s'éxecute même pas)
        let ekEvent = EKEvent(eventStore: self.eventStore)

        // On peuple l'event avec les données envoyé par le controlleur Stimulus        
        ekEvent.title = calData["summary"] as? String ?? ""
        ekEvent.notes = calData["description"] as? String ?? ""
        ekEvent.location = calData["location"] as? String ?? ""
        ekEvent.url = URL(string: calData["url"] as? String ?? "")
        ekEvent.calendar = self.eventStore.defaultCalendarForNewEvents
        
        if let startDateData = calData["start_date"] as? [String: Any],
           let startDateString = startDateData["date"] as? String,
           let startDate = dateFromString(dateString: startDateString, timeZone: startDateData["timezone"] as? String) {
            ekEvent.startDate = startDate
        }
        
        if let endDateData = calData["end_date"] as? [String: Any],
           let endDateString = endDateData["date"] as? String,
           let endDate = dateFromString(dateString: endDateString, timeZone: endDateData["timezone"] as? String) {
            ekEvent.endDate = endDate
        }
        
        // On créer le controlleur qui affiche la fenêtre d'ajout au calendrier
        let editViewController = EKEventEditViewController()
        editViewController.eventStore = self.eventStore
        editViewController.editViewDelegate = self
        editViewController.event = ekEvent

        // Quel controlleur on utilise ?
        if (openInModalController){
            modalNavigationController.present(editViewController, animated: true, completion: nil)
        } else {
            navigationController.present(editViewController, animated: true, completion: nil)
        }
    }

    // Conversion des dates de PHP en date pour Swift
    func dateFromString(dateString: String, timeZone: String?) -> Date? {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
        if let timeZone = timeZone {
            dateFormatter.timeZone = TimeZone(identifier: timeZone)
        }
        return dateFormatter.date(from: dateString)
    }
    
    // Comme pour l'email, on doit fermer la fenêtre nous même
    public func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) {
        controller.dismiss(animated: true, completion: nil)
    }
}

Ah that's right, there's more code there...
The things to watch out for, and that I wasted time on, are : creating the event before you have the right to write to the calendar will cause you to "crash" the addition to the calendar, the "state" of the "eventStore" linked to the event, and the "state" of the "calendar" linked to the event.eventStore" linked to the event (yes, you have to keep up) is not automatically updated when you create the event before requesting authorization...

How about a video to see the final rendering?

Changing the view background color

My application is rather dark, to recall the interior of the restaurant. The problem is that when the "WKWebView" is loaded, you see the basic (native) view of the application, and by default, "Turbo Native" uses white as the background color.

To change it, go to this line.
So, apart from forking Turbo Native to modify it, I haven't found any other solution.

Publishing on the App Store

To publish on the App Store, you need a developer account, which will cost you €99 per year.

Once you have your account, go to "App Store Connect" and create your application in the interface, then upload it via "XCode".

The verification process can be quite stressful, but in my case, having dealt with Apple in the past, I know what they're looking at.I must admit that when I received the email telling me that the application had been validated, it was a great relief.

Conclusion

The idea here was to highlight the fact that Turbo Native requires a minimum interest in how iOS works on the Internet in order to publish an application on this platform.

I preferred to use real-life examples rather than talk about theory for a long time 🙂.

Have a great day and see you soon 😁.