Strada探検隊

f:id:laiso:20211009143132p:plain
Strada

こんばんわ、Strada探検隊のお時間です。

Stradaとは?

Turbo NativeとモバイルOSのネイティブAPIを連携させるHotwireシリーズ最後のミッシングパーツだと目されています*1

Stradaは目下開発中の身であり全容が分っていないのですが、既にHEYアプリに投入されているため、HEY iOSアプリの構造を見て取ることでその仕組みが予測できるのではないかと考えました。

計画

Standardizes the way that web and native parts of a mobile hybrid application talk to each other via HTML bridge attributes. https://hotwired.dev/

とあるので特別なHTML定義をStradaが解釈してUIコンポーネントへ受け渡すことが予想できます。なので特殊なHTMLを経由していそうな箇所を探します。

turbo-ios

hotwired/turbo-ios: iOS framework for making Turbo native apps

turbo-ios をおさらいします。turbo-iosはturboで作成したウェブページをiOSのWKWebViewで動作させるために追加で入れるフレームワークです。

turboで作成したウェブページはそれ単体でもWebViewで動くただのWebアプリではあるのですが、画面制御や認証情報をOS側に保存するなどのWebViewの外で実行する必要のあるコードをturbo-iosを使って実装します。なのでStimulus*2のネイティブ版的な役割だと解釈しました。

ドキュメントには

This is fine for simple tasks, but we've found we need something more comprehensive for our apps, which is why we created a new framework called Strada. https://github.com/hotwired/turbo-ios/blob/956484e3524eaf203d12807313e7119427a5771d/Docs/Advanced.md

とあるので、WebViewの拡張ではなくてアプリ内ブラウザを作ってそこに自分たちのWebViewを乗せたいという意図を感じます。

ログイン直後

HEYアプリでログインをすると。JSONでアプリのロードに必要なファイルを取得しています。Web版にはない挙動なのでこれがアプリ実装独自のものであることがわかります。

スクリーンショットは市民の権利であるところの Charles Proxy を使用しました

navigation.json

{"items":
[{"title":"Imbox","app_url":"https://app.hey.com/imbox","hotkey":"command+1","highlighted":false,"icon":{"name":"imbox","android_url":"https://production.haystack-assets.com/assets/icons/imbox-79d9f42c3186cc31d1032da369799121be75c8a2ec6dd32210c45bbae96662f4.svg","ios_url":"https://production.haystack-assets.com/assets/icons/ios/imbox-2bbd76a4e5cba992f779de32a6c5820426e35f31bb9560284f6373cc87a46678.png"}},
{"title":"The Feed","app_url":"https://app.hey.com/feedbox","hotkey":"command+2","highlighted":false,"icon":{"name":"feedbox","android_url":"https://production.haystack-assets.com/assets/icons/feedbox-b595638e37da5175c6ef848245712f0037964fccf0106b90e2b0de95545788ac.svg","ios_url":"https://production.haystack-assets.com/assets/icons/ios/feedbox-cc4dd8e4c3a28175e54b4e8e7fd0a87f5414658beaeff5af0c33d3ebe473ac8c.png"}},
// ...

データから推測するとフッタのナビゲーションウィンドウの中身だと分かります。Server-driven UI っぽい*3

ios-v3.json

正規表現 => properties の定義が配信されてくる。"presentation": "modal" とあるのでURLのパターンにマッチした画面の制御を行っている仕組みだと思いました。

これはturobo-iosのPath Configuration機能らしい *4

  "rules": [
    {
      "patterns": [
        "/recede_historical_location"
      ],
      "properties": {
        "presentation": "back",
        "visitable": false
      }
    },
    {
      "patterns": [
        "/new$",
        "/edit$",
        "^/topics/[0-9]+/publication",
        "forwardings/outbounds",
        "contacts/[0-9]+/box_settings",
        "contacts/[0-9]+/notification_settings",
        "/projects/[0-9]+/status",
        "/collections/[0-9]+/status",
        "/my/preapproval",
        "/folders/[0-9]+/confirm_destroy",
        "^/mailto/",
        "postings/projects/[0-9]+/add_topics",
        "postings/collections/[0-9]+/add_topics",
        "/my/accounts/[0-9]+/external_accounts/[0-9]+/trash",
        "domains/[0-9]+/auto_screening",
        "domains/[0-9]+/notification_settings",
        "/autofileables/.*/autofilings",
        "/attachments/senders"
      ],
      "properties": {
        "presentation": "modal",
        "always-dismiss": true
      }
    },

topics

メールの内容や返信がtopicsの単位になっている。ここはWeb版とほぼ同じなので普通にWebViewでHTMLを受け取ってturb-frameで更新しているのだと思われる。

toolbar

topic下部に出てくるメニューのこと。turbo-frameで取得されている。

class="u-hide@mobile" はデスクトップとモバイル版で出し分けをするために定義されている。

<turbo-frame id="topic_toolbar">
  <turbo-frame id="topic_composer" data-controller="disable-on-mobile reset-frame-source">
      <div class="page-toolbar ">
        <div class="page-toolbar__content" data-controller="action-bar bridge--action-bar" data-action="action-bar:updateSelection->bridge--action-bar#updateSelection" data-action-bar-highlight-class="page-toolbar__action--selected" data-bridge--action-bar-highlight-class="page-toolbar__action--selected">
          <div class="page-toolbar__item">
      <a role="button" data-topic-typing-target="replyPrompt" data-controller="hotkey bridge--hotkey" data-hotkey="r,R" data-bridge-hotkey="command+r" data-bridge--action-bar-target="item" data-bridge-title="Reply" data-bridge-icon-name="reply" data-bridge-icon-android-url="https://production.haystack-assets.com/assets/icons/android/reply-fae46c7a6889c98d1ac6439182bc23fb1eafcb01a4a7db45f042101a98569668.svg" aria-label="Reply now" class="page-toolbar__action page-toolbar__action--reply btn--focusable" href="/entries/460727690/replies/new">
        <span class="u-hide@mobile">Reply Now</span>
        <span class="u-hide@desktop">Reply</span>
        <kbd class="page-toolbar__action-hotkey action-group__action-hotkey">r</kbd>
</a></div>

momd

更に奥地へ進んで行きます。念力を使ってパッケージバンドルを透視したところmomdが含まれているのでCore Dataを使っているのが分かりました*5

Haystack.momd/
├── Haystack.mom
└── VersionInfo.plist

アプリ向けDBモデルが構築されていることが分かります

turbo-iosはWebViewとその制御が責任範囲で、ローカルストレージのDBをどうするのかという部分は独自に実装されていると思います。なのでここはStradaっぽさがある

composer

composer.html からなる小さなローカルWebアプリがある。trix-core.jsが組込まれていることからメールエディタの部分ではないかと思われる。

これだけローカルに含まれているのはオフライン時にメール書いたり、とかの機能を担保するのが目的かもしれない。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
  <title></title>
  <link rel="stylesheet" type="text/css" href="hey-composer://localhost/_bundle/trix.css">
  <link rel="stylesheet" type="text/css" href="hey-composer://localhost/_bundle/composer.css">
  <script type="text/javascript" src="hey-composer://localhost/_bundle/trix-core.js"></script>
</head>
<body>
  <form>
    <input id="content" value="" type="hidden" name="content">
    <trix-editor class="entry-composer__textarea trix-content" id="editor" input="content" data-blob-url-template="/rails/active_storage/blobs/redirect/:signed_id/:filename"></trix-editor>
  </form>
</body>
</html>

HEYのカスタムスキーマhey:// のはずなので hey-composer:// は内部リソースを読み込むために解釈される? なぞの部分。

strada.js

Strada本体のJavaScript部分。

数十行のファイルで、やっていることは document.addEventListener() して postMessage() するだけ。これ自体は検索すれば出てくるぐらいに、WebViewとネイティブでメッセージングした時によくある実装方法だと思う*6

Strada_Strada.bundle/
├── Info.plist
└── strada.js
document.addEventListener("web-bridge:ready", () => window.webBridge.setAdapter(this))
// ...
window.nativeBridge = new NativeBridge()
window.nativeBridge.postMessage("ready")

他にコンポーネントregister/unredister 関数がある。おそらく最低限のサンドボックスの仕組みだと思う。

その後

他にも念力を駆使しようと試行錯誤したけど、新たな発見はしばらく得られなかったので我々はStradaを後にした。

僕より強いサイキッカーの人が挑めば何か収穫があるのかもしれない……

わかったこと

  • HTML Over The Wire と言えどHEYアプリには多数のJSONレスポンスを返すAPI呼び出しがある。
  • モバイルアプリだけ独自 <strada-feature></strada-feature> タグとか謎の通信してるのを期待したが思ったより普通のネイティブアプリ+JSON APIの部分しか観測できなかった
  • Server-driven UI っぽいものをがんばろうとしているのは明かだった。でもこれはAirbnb事例を見てもStradaに限らず業界的なトレンドであるとは思う

豆知識

  • HEYのプロジェクトのコードネームは Haystack *7
  • Basecampの人々はJSの行末セミコロン書かない派