21
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

GoodpatchAdvent Calendar 2021

Day 20

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

Last updated at Posted at 2021-12-20

IMG_0055.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 sakanaction.jp
Simulator Screen Shot - iPhone 13 mini - 2021-12-20 at 21.37.42.png Simulator Screen Shot - iPhone 13 mini - 2021-12-20 at 21.38.02.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

続いて適切なProduct情報を入力し、次へ進みます。
image.png

これでSafari Extensionを開発する準備が整いました。
今回の機能拡張の開発ではmetachecker > metachecker Extension > Resourcesの内部のみに実装を施していきます。
image.png

2. 実装方針

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

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

今回の機能拡張は以下のように、browserActionとcontentScripts間での双方向通信を実装することでpopupでメタ情報を参照できるように構築します。
image.png

3. 実装

3-1. manifest

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

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

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

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

3-2. ブラウザアクション

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

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

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です。

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

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

`popup.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>
`popup.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;
    }
}

3-3. コンテンツスクリプト

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

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

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サイトへのアクセス権を許可します。

metachecker extensionの設定インタフェース

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

nogizaka46.com hinatazaka46.com
Simulator Screen Shot - iPhone 13 mini - 2021-12-20 at 21.24.17.png Simulator Screen Shot - iPhone 13 mini - 2021-12-20 at 21.27.52.png

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

今回作成したプロジェクトはこちらのGitHubリポジトリにプッシュしております。
ore0/metachecker

終わりに

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

参考文献

21
8
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?