サイトのメタ情報を教えてくれる Safari Extension (機能拡張) の作り方

![IMG_0055.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/283ce576-b5d1-13db-0881-f1b7984124b7.png)

2021年9月にリリースされたiOS 15から、**Safari Extensions**が導入されました。
このアップデートによってiPhoneやiPadのSafariに、よりブラウジングを快適にするための機能拡張を追加できるようになりました。

Safari ExtensionsはHTML, CSS, JavaScriptなどの技術で実装を行うため、Webエンジニアにとって開発がしやすいものとなっています。
これはiOS開発に明るくないWebエンジニアでもApp Storeにアプリケーションを配信するチャンスが広がったと言えるのではないでしょうか。

私自身もSwift, Obj-Cを書いたことはおろか、Xcodeも数える程度しか触れたことがありませんでしたが、iOS 15が配信されてからSafari Extensionを実装し、App Storeにてアプリケーションをリースをすることができました。

今回はそのような経験を元に、Safari Extensionの実装方法についてご紹介できればと思います。
本記事はXcodeでの実装経験が少ないWebエンジニア向けの解説となります。

## 作成する機能拡張について
今回はメタ情報を表示するための機能拡張をサンプルとして作成します。
この機能拡張を実装できるようになると、Webサイトからの情報の取得方法やモーダルの作り方を理解することができるようになります。

| [goodpatch.com](https://goodpatch.com/) | [sakanaction.jp](https://sakanaction.jp/) |
|--|--|
| ![Simulator Screen Shot - iPhone 13 mini - 2021-12-20 at 21.37.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/8b4bc3f2-79cb-70e0-df0e-a70a7672e90c.png) | ![Simulator Screen Shot - iPhone 13 mini - 2021-12-20 at 21.38.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/e1e3500a-c426-3da0-4f9a-b61b956dd38a.png) |



## 作り方
- 開発環境
    - macOS: 11.6.1 (Big Sur)
    - Xcode: 13.2.1

### 1. プロジェクトの準備
Xcodeを立ち上げ、「**Creating a new Xcode project**」から新規プロジェクトを立ち上げます。
テンプレート選択モーダルにて、iOS Applicationのテンプレート一覧に「**Safari Extension App**」の項目が追加されているので選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/e07f831e-d216-e6af-fdb4-8a722130ba15.png)

続いて適切なProduct情報を入力し、次へ進みます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/0b794eb0-25d8-5e72-45c5-65d12c6d306b.png)

これでSafari Extensionを開発する準備が整いました。
今回の機能拡張の開発では`metachecker > metachecker Extension > Resources`の内部のみに実装を施していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/b74adf97-57b5-c857-5b04-b023290f5a2a.png)


### 2. 実装方針
本記事の機能拡張では「**browserAction**」と「**content scripts**」の二箇所を実装します。
両者は大まかに以下のように定義されるAPIです。

- **browserAction**
    - 一般的にポップアップと呼ばれる物です。ポップアップはiOS Safariの拡張機能であっても、HTML, CSS, JavaScriptといったWeb技術を用いて構築します。 ポップアップ内のスクリプトはWebExtension APIにアクセスできますが、グローバルコンテキストはブラウザに表示されている現在のページではなくポップアップになります。Webサイトにアクセスするにはmessages経由で通信する必要があります。
    - [browserAction - Mozilla | MDN](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/browserAction)
- **contentScripts**
    - コンテントスクリプトを用いることで、指定したURLにマッチするページにそのスクリプトを挿入するようブラウザに指定することができるようになります。グローバルコンテキストは埋め込み先のページとなるため、Webサイトのコンテンツにアクセスすることが可能です。
    - [contentScripts - Mozilla | MDN](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/contentScripts)

今回の機能拡張は以下のように、browserActionとcontentScripts間での双方向通信を実装することでpopupでメタ情報を参照できるように構築します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/280f89c8-06a5-7fcb-d508-478c9d376a76.png)


### 3. 実装
#### 3-1. manifest
まず初めにmanifest.jsonにて機能拡張の設定を記述します。
**`permissions`**に対して、拡張機能から現在展開されているタブにアクセスするための権限(`tabs`)と、全てのWebサイトで機能拡張を有効にするための権限(`<all_urls>`)を追加します。

```json:manifest.json
    "permissions": [
        "tabs",
        "<all_urls>"
    ]
```

また、**`content_scripts`**の`ｍatches`に先程有効化した`<all_urls>`を指定します。
この記述によって機能拡張が全てのWebサイトにて使用することができるようになります。

```json:manifest.json
    "content_scripts": [{
        "js": [ "content.js" ],
        "matches": [ "<all_urls>" ]
    }]
```

#### 3-2. ブラウザアクション
続いて機能拡張のUI部分であるpopupの実装を行います。今回のpopupの仕事はブラウザのアクティブなタブにアクセスした上で、タブからメタ情報を取得してpopupのDOMに挿入することです。

`popup.js`にレスポンスハンドラ及び、エラーハンドラを記述します。
レスポンスハンドラでは`type`が`metas`だった場合はレスポンスに含まれているメタ情報をDOMに挿入するように処理を振り分けます。
また、`browser.tabs.query`を用いてブラウザ上ののアクティブなタブを取得し、sendMessageでそのタブへメッセージを送信します。

```js:popup.js
function handleResponse(response) {
    const data = response.data;
    switch(response.type) {
        case "metas":
            document.getElementById("title").innerHTML = data.title;
            document.getElementById("description").innerHTML = data.description;
            document.getElementById("keywords").innerHTML = data.keywords;
            break;
        default:
            break;
    };
}

function handleError(error) {
  console.log(`Error: ${error}`);
}

browser.tabs.query({active:true, currentWindow:true}, (tabs) => {
  browser.tabs.sendMessage(tabs[0].id, {
    type: "popup"
  }).then(handleResponse, handleError);
});

```

ここでpopupのUIも記述しておきましょう。
基本的には`title`, `description`, `keywords`のそれぞれに対応する要素に`popup.js`で指定した`id`を振ってあげるだけでOKです。

```html:popup.html
<body>
    <p id="title"></p>
    <p id="description"></p>
    <p id="keywords"></p>
</body>
```

厳密には以下のような実装を行いました。
popupはDOMで構築されていますが、SwiftUIを模して構築してみました。(地味ですがダイナミックタイプやダークテーマにも対応しております。)

<details>
<summary>`popup.html`</summary>
<div>

```html:popup.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="popup.css">
    <script type="module" src="popup.js"></script>
</head>
<body>
    <div class="section">
        <h2>Meta</h2>
        <dl class="list">
            <div class="list__item">
                <dt class="list__item__label">title</dt>
                <dd class="list__item__content" id="title"></dd>
            </div>
            <div class="list__item">
                <dt class="list__item__label">description</dt>
                <dd class="list__item__content" id="description"></dd>
            </div>
            <div class="list__item">
                <dt class="list__item__label">keywords</dt>
                <dd class="list__item__content" id="keywords"></dd>
            </div>
        </dl>
    </div>
</body>
</html>
```
</div>
</details>

<details>
<summary>`popup.css`</summary>
<div>

```css:popup.css
:root {
    color-scheme: light dark;
}

*, *::before, *::after {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    -webkit-user-select: none;
    user-select: none;
}

html {
    width: 100%;
}

body {
    min-width: 288px;
    width: 100%;
    padding: 35px 16px;
    background: #F2F2F6;
    font-family: system-ui;
}

h2 {
    margin-left: 16px;
    font: -apple-system-caption1;
    color: #86858C;
}

.section:not(:first-child) {
    margin-top: 35px;
}

.list {
    background: #FEFFFF;
    border-radius: 10px;
    overflow: hidden;
}

.list:not(:first-child) {
    margin-top: 4px;
}

.list__item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 12px 16px;
    font: -apple-system-body;
}

.list__item:not(:first-child) {
    position: relative;
    margin-top: 0.5px;
}

.list__item:not(:first-child)::before {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    display: block;
    width: calc(100% - 16px);
    height: 0.5px;
    background: #C6C6C6;
    padding-left: 16px;
}

.list__item__label {
    width: 30%;
    word-break: break-all;
}

.list__item__content {
    width: calc(70% - 16px);
    margin-left: 16px;
    color: #8F8F8F;
    word-break: break-all;
}

@media (prefers-color-scheme: dark) {
    body {
        background: #000000;
    }
    
    h2 {
        color: #8E8D94;
    }
    
    .list {
        background: #1C1C1E;
    }
    
    .list__item:not(:first-child)::before {
        background: #3D3D40;
    }

    .list__item__content {
        color: #98989F;
    }
}
```
</div>
</details>

#### 3-3. コンテンツスクリプト
最後に`content`の記述を行います。`content`はブラウザのDOMに直接アクセスできる唯一のスクリプトです。
今回の`content.js`の役割は、`popup.js`から`sendMessage`を用いて送られてきたメッセージを`browser.runtime.onMessage`を用いて受け取り、HTML内のメタデータを取得した上で返却することです。

`contents.js`では初めに、メッセージのハンドラで`popup`からのメッセージがあった場合は`metas`typeと各種メタデータを返却するように記述します。
続いて`browser.runtime.onMessage`のイベントリスナーを追加し、レスポンスを待機状態にします。

```js:content.js
function handleMessage(request, sender, sendResponse) {
    switch(request.type) {
        case "popup":
            sendResponse({
                type: "metas",
                data: {
                    title: document.title ? document.title : "",
                    description: document.querySelector('meta[name="description"]') ? document.querySelector('meta[name="description"]').content : "",
                    keywords: document.querySelector('meta[name="keywords"]') ? document.querySelector('meta[name="keywords"]').content : "",
                }
            });
            break;
        default:
            break
    }
};

browser.runtime.onMessage.addListener(handleMessage);
```

以上で機能拡張の実装は完了となります。

### 4. 起動
ここまで完了したらシミュレーターで機能拡張を実行してみましょう。
`⌘R`か、Xcode内の左上のRunボタン（▶）からシミュレーターを起動できます。

設定アプリを開き、Safari > 機能拡張 > metachecker Extension と順に展開します。
ここで機能拡張を有効にし、すべてのWebサイトへのアクセス権を許可します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/925c8aa0-77bb-bd5a-ef9f-880c28d811e3.png" alt="metachecker extensionの設定インタフェース" width="240">

続いてSafariを開き、任意のサイトでアドレスバー内の「ぁあ」ボタンから機能拡張のポップアップを展開するとメタ情報が表示されているはずです。

| [nogizaka46.com](https://www.nogizaka46.com/) | [hinatazaka46.com](https://www.hinatazaka46.com/) |
|--|--|
| ![Simulator Screen Shot - iPhone 13 mini - 2021-12-20 at 21.24.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/ca499cd6-a7f5-b6c8-6d4e-b4a9157b5347.png) | ![Simulator Screen Shot - iPhone 13 mini - 2021-12-20 at 21.27.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48027/47c8b889-cb69-966b-1ad4-39118f80819a.png) |

サイトによってはメタタグの指定が`<meta name="description">`ではなく`<meta name="Description">`のように、大文字が使用されている場合もあり、正しく表示されないケースもあるかと思います。そのような場合は`content.js`を編集してあげましょう。

今回作成したプロジェクトはこちらのGitHubリポジトリにプッシュしております。
[ore0/metachecker](https://github.com/ore0/metachecker)

## 終わりに
Safari ExtensionsはWebの技術で実装を行うため、iOS上の体験でありながらも、iOSエンジニアよりもWebエンジニアの方が開発に向いているかと思います。
この機会に機能拡張を実装して、Safariでのブラウジング体験をより良い物にアップデートしてみませんか？
App Storeでみなさんが開発した機能拡張に出会えることを楽しみにしています。

## 参考文献
- [Meet Safari Web Extensions on iOS - WWDC21 - Videos - Apple Developer](https://developer.apple.com/videos/play/wwdc2021/10104/)
- [Explore Safari Web Extension improvements - WWDC21 - Videos - Apple Developer](https://developer.apple.com/videos/play/wwdc2021/10027)
- [Meet Safari Web Extensions - WWDC20 - Videos - Apple Developer](https://developer.apple.com/videos/play/wwdc2020/10665)