WWDC Quick Look 💓 By SwiftGGTeam
Safari向けWeb拡張機能を作る

Safari向けWeb拡張機能を作る

元の動画を見る

ハイライト

Safariは、純粋なHTML、CSS、JavaScriptでWeb拡張機能を開発できるようになりました。Xcodeなしでローカルデバッグでき、App Store Connectを通じてiOS、iPadOS、macOS、visionOSへ直接パッケージ化して配布できます。

主要内容

以前Safari拡張機能を作るには、まずXcodeを開いてmacOS/iOSアプリを作り、その中に拡張機能を埋め込む必要がありました。フロントエンド開発者にとって、この流れは敷居が高く、フィードバックも遅く、多くの人が途中で諦めていました。WWDC26のこのセッションは、その状況を大きく変えます。

AppleはいまW3C Web Extensions標準を全面的に受け入れています。必要なのはコードエディタ、manifest.json、そして数個のHTML/CSS/JSファイルだけです。それらをSafariへ直接読み込み、デバッグできます。開発体験はChrome拡張機能を書く場合とほとんど変わりません。

セッションでは、“Shiny OnTrack”という実例が紹介されます。これは集中を妨げるサイトをブロックする拡張機能です。2つのモードがあり、Fullモードはアクセスを直接ブロックし、Lightモードはページ上に10分のカウントダウンを表示します。この拡張機能をゼロから作り、App Storeへ公開するまでの全体の流れが、このセッションの軸になっています。

開発段階でXcodeは不要に

(03:23)

Safari設定を開き、Advancedパネルで”Show features for web developers”を有効にすると、Extensionsタブから未署名のローカル拡張機能を読み込めます。フォルダを選び、未署名拡張機能を許可するだけで、3ステップで読み込み完了です。コードを変更したらReloadをクリックすれば、すぐに結果を確認できます。

これは以前のXcode Build & Run必須の反復速度とはまったく別物です。

権限モデル: 必要最小限のアクセス

(07:53)

Safariの権限設計はユーザーのプライバシーを優先します。拡張機能が必要とする能力は、manifest.jsonで宣言しなければなりません。Appleは、最初から全サイト権限を要求するのではなく、optional_host_permissionsを使い、ユーザーが具体的な機能を起動したときにアクセスを求めることを推奨しています。ユーザーが許可すると拡張機能アイコンがアクティブになり、そのページで拡張機能が動いていることを常に示します。

1コマンドでパッケージ化して配布

(22:49)

開発が完了したら、1つのコマンドでXcodeプロジェクトを生成できます。

xcrun safari-web-extension-packager --copy-resources /path/to/extension

App Store Connectは、拡張機能リソースの直接アップロードと自動パッケージ化にも対応しました。Macすら不要です。アップロード後はTestFlightで内部テストし、App Reviewへ提出します。流れは通常のアプリと同じです。

Native Messagingでネイティブ機能へ橋渡し

(23:02)

拡張機能が生体認証などのシステムレベル機能を呼び出す必要がある場合は、Native Messagingで内包するネイティブアプリと通信できます。JavaScriptがApp Extensionへメッセージを送り、App Extensionがネイティブアプリへ転送し、結果が同じ経路で戻ります。セッションでは、Touch IDで設定ページを保護する完全なコードが紹介されました。

詳細内容

Manifest V3の基本設定

(03:44)

すべての拡張機能はmanifest.jsonから始まります。

{
    "manifest_version": 3,
    "name": "Shiny OnTrack",
    "description": "Stay on track while you browse the web",
    "version": 1.0,
    "icons": {
        "512": "images/icon.svg"
    },
    "options_ui": {
        "page": "options.html"
    }
}

ポイント:

  • manifest_versionは3でなければなりません。これは現在のW3C標準です
  • アイコンをSVGにすると、Safariが複数解像度への拡大縮小を自動処理します
  • options_uiは設定ページを定義します。複雑な設定にはtoolbar popupより向いています

declarativeNetRequestでリクエストをブロック・リダイレクトする

(08:18)

コンテンツブロックは拡張機能で最もよくある要件です。AppleはdeclarativeNetRequest APIを提供しています。ルールはmanifestに静的に書くことも、実行時に動的追加することもできます。

静的ルールは既知のブロックリストに向いています。

{
    "permissions": ["declarativeNetRequest"],
    "declarativeNetRequest": {
        "rule_resources": [
            {
                "id": "ruleset_id",
                "enabled": true,
                "path": "rules.json"
            }
        ]
    }
}

動的ルールはユーザー定義の場面に向いています。セッションでは、ドメイン名を一意のルールIDへ写像するhelperが紹介されました。

export function hostToRuleID(host) {
    let hash = 0;
    for (let i = 0; i < host.length; i++) {
        hash = ((hash << 5) + hash) + host.charCodeAt(i);
        hash |= 0;
    }
    return Math.abs(hash) || 1;
}

function createBlockRule(host) {
    return {
        id: hostToRuleID(host),
        priority: 1,
        action: { type: "block" },
        condition: {
            urlFilter: `||${host}`,
            resourceTypes: ["main_frame"]
        }
    };
}

export async function createRules(hosts) {
    try {
        await browser.declarativeNetRequest.updateDynamicRules({
            addRules: hosts.map(createBlockRule)
        });
    } catch {
        console.log("Failed to create declarative net request rules");
    }
}

ポイント:

  • hostToRuleIDは文字列ハッシュで安定したIDを生成し、同じドメインは毎回同じIDになります
  • urlFilter: ||${host}はそのドメインとすべてのサブドメインに一致します
  • resourceTypes: ["main_frame"]はメインドキュメントのナビゲーションだけをブロックし、サブリソースには触れません
  • 動的ルールはupdateDynamicRulesでまとめて追加・削除します

カスタムページへリダイレクトする

(10:48)

単純にblockすると、ユーザーには無機質なエラーページが表示されます。拡張機能内のカスタムページへリダイレクトすると、体験はずっと良くなります。ただし、リダイレクトにはより高い権限が必要です。

{
    "permissions": ["declarativeNetRequestWithHostAccess"],
    "optional_host_permissions": ["*://*/*"]
}

リダイレクトルール:

function createRedirectRule(host) {
    return {
        id: hostToRuleID(host),
        priority: 1,
        action: {
            type: "redirect",
            redirect: { extensionPath: "/blocked.html" }
        },
        condition: {
            urlFilter: `||${host}`,
            resourceTypes: ["main_frame"]
        }
    };
}

ポイント:

  • 通常のdeclarativeNetRequestではなく、declarativeNetRequestWithHostAccessを使います
  • extensionPathは拡張機能パッケージ内のローカルファイルを指します
  • リダイレクトを有効にするには、対象ドメインのhost permissionを先に取得する必要があります

実行時に権限を要求する

(13:42)

ユーザーがドメインを追加すると、拡張機能は動的に権限を要求します。

export async function addHost(host, blockingMode) {
    if (!host) return;

    const granted = await browser.permissions.request({
        origins: [`*://${host}/*`, `*://*.${host}/*`]
    });
    if (!granted) return;

    if (blockingMode === "full")
        await createRules([host]);
}

ポイント:

  • permissions.requestはシステム権限ダイアログを表示し、ユーザーが許可した場合だけ続行します
  • 拒否されたらすぐreturnし、後続処理は実行しません
  • この「必要になったときに要求する」方式は、最初に<all_urls>を一括要求するよりユーザーの信頼を得やすくなります

Content Scriptsをページへ注入する

(14:55)

Lightモードでは対象ページ上にカウントダウンを表示する必要があります。そこでContent Scriptsを使います。

function contentScript(host) {
    return {
        id: `cs-${host}`,
        js: ["content.js"],
        css: ["content.css"],
        matches: [`*://${host}/*`, `*://*.${host}/*`],
        persistAcrossSessions: true
    };
}

export async function registerScripts(hosts) {
    const scripts = hosts.map(contentScript);
    try {
        await browser.scripting.registerContentScripts(scripts);
    } catch {
        console.log("Failed to register content scripts");
    }
}

ポイント:

  • persistAcrossSessions: trueにより、Safari再起動後もスクリプト登録が残ります
  • 各ドメインに独立したIDを割り当て、衝突を避けます
  • scripting権限が必要です

Storage APIでデータを永続化する

(17:06)

拡張機能を再起動してもデータが失われてはいけません。Safariは2種類のストレージを提供します。

  • browser.session.storage: メモリ上のストレージで、再起動すると消えます
  • browser.storage.local: ディスク上のストレージで、永続保存されます
export async function updateHosts(hosts) {
    await browser.storage.local.set({ hosts: hosts });
}

export async function getHosts() {
    const { hosts = [] } = await browser.storage.local.get("hosts");
    return hosts;
}

export async function saveBlockMode(mode) {
    await browser.storage.local.set({ blockMode: mode });
}

export async function getBlockMode() {
    const { blockMode = "full" } = await browser.storage.local.get("blockMode");
    return blockMode;
}

ポイント:

  • storage.localはディスクへ書き込むため、ユーザー設定やブロックリストに向いています
  • 初回利用時のundefinedを避けるため、読み取り時は分割代入で既定値を設定します

Background Scriptでライフサイクルを処理する

(19:01)

Content Scriptsは拡張機能の更新後に失われることがあるため、background scriptで再登録する必要があります。

browser.runtime.onInstalled.addListener(async (details) => {
    if (details.reason !== "update") return;

    const hosts = await getHosts();
    await registerScripts(hosts);
});

ポイント:

  • onInstalledは拡張機能のインストール時または更新時に発火します
  • reason !== "update"でフィルタし、重複実行を避けます
  • storageから保存済みドメイン一覧を読み、Content Scriptsを復元します

Native Messagingでシステム機能を呼び出す

(23:32)

拡張機能はNative Messagingを通じて、内包するネイティブアプリと通信します。JavaScript側:

export async function requestBioAuth() {
    const message = { message: "requestBioAuth" };
    const response = await browser.runtime.sendNativeMessage(message);
    return response?.success;
}

Swift側ではSafariWebExtensionHandlerで処理します。

import LocalAuthentication

class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
    func beginRequest(with context: NSExtensionContext) {
        let request = context.inputItems.first as? NSExtensionItem
        let message = request?.userInfo?[SFExtensionMessageKey] as? [String: Any]

        if message?["message"] as? String == "requestBioAuth" {
            let laContext = LAContext()
            Task {
                do {
                    let success = try await laContext.evaluatePolicy(
                        .deviceOwnerAuthenticationWithBiometrics,
                        localizedReason: "Authenticate to change blocked sites"
                    )
                    self.reply(context: context, success: success)
                } catch {
                    self.reply(context: context, success: false)
                }
            }
        }
    }

    private func reply(context: NSExtensionContext, success: Bool) {
        let response = NSExtensionItem()
        response.userInfo = [SFExtensionMessageKey: ["success": success]]
        context.completeRequest(returningItems: [response], completionHandler: nil)
    }
}

ポイント:

  • sendNativeMessageはJSONメッセージをApp Extensionへ送ります
  • App ExtensionはNSExtensionRequestHandlingでメッセージを受け取ります
  • LocalAuthenticationフレームワークで生体認証を行います
  • 結果はcompleteRequestを通じてJSへ戻されます

重要ポイント

1. クロスブラウザのパスワード管理拡張機能を作る

Safariはいま標準のWebExtensions APIを使っているため、Chrome/Firefox拡張機能のコードをそのまま再利用できます。自動入力やパスワード生成などの中核ロジックを純粋なJSで作り、Safari向けにはApp Store配布用の外側を用意します。Native Messagingを使えば、iOS Keychainや生体認証によるロック解除を呼び出せます。

入口: 既存のManifest V3コードを再利用し、browser.storage.localと権限モデルへの適応を重点的に確認します。

2. Webページのハイライト・メモツールを作る

Content Scriptsは任意のWebページのDOMを読み書きできます。scripting.registerContentScriptsでハイライト処理を動的に注入し、storage.localでハイライト位置とメモ内容を保存します。デバイス間同期は、Native Messagingを通じてiCloud Key-Value Storageへ橋渡しできます。

入口: まずcontent.jsでテキスト選択の取得とハイライト描画を実装し、その後storageの永続化を接続します。

3. 集中モード拡張機能を作る

セッションの”Shiny OnTrack”を参考にしつつ、さらに賢くできます。たとえば時間帯によってブロックリストを切り替える(勤務時間はSNSをブロックし、夜は仕事メールをブロックする)、あるいはスクリーンタイムに応じてブロック強度を動的に調整する、といった形です。declarativeNetRequest.updateDynamicRulesは実行時の一括ルール更新に対応しており、このような動的戦略を十分支えられます。

入口: optionsページで複数のルールセットを設定し、background scriptが時間やイベントに応じてルールセットを切り替えます。

4. Web自動テスト支援ツールを作る

Content Scriptsはテストスクリプトの注入、ページ性能データの収集、スクリーンショット比較に使えます。browser.scripting.executeScriptで特定ページへ一時的にテストコードを注入し、結果をNative Messagingでネイティブアプリへ戻して可視化レポートを作れます。

入口: scripting APIで対象ページにテストrunnerを注入し、storage.localでテスト結果をキャッシュします。

5. 既存のChrome拡張機能をSafariへ移行する

すでにManifest V3のChrome拡張機能があるなら、移行コストは非常に低いです。主な確認点:

  • chrome.* APIをbrowser.*へ置き換える(Safariは両方の名前空間に対応しています)
  • 広いhost permissionsをoptional_host_permissionsへ変更する
  • background scriptがpersistent pageではないことを確認する(Safariはservice workerまたはnon-persistent background pageを要求します)

入口: safari-web-extension-packagerでXcodeプロジェクトを一発生成し、テストに通ったらApp Storeへ提出します。

関連セッション

  • 204 - WebKit — Safari 27の新しいWebプラットフォーム機能。拡張機能開発者が下層のWebKit対応状況を理解するのに役立ちます
  • 314 - CSS Grid — 拡張機能のUIページ(optionsやpopup)は本質的にWebページなので、CSS Gridの新機能をそのまま利用できます
  • 320 - Immersive web — 拡張機能がvisionOS Safariで空間的な体験を提供する場合、没入型Web標準の理解が必要です
  • 344 - Code-along: Build a great experience for Siri — 拡張機能はApp Intentsを通じてSiriと統合でき、ユーザーが音声で拡張機能を操作できます
  • 370 - TextKit — ページ内テキスト処理(ハイライトや翻訳など)を扱う拡張機能では、TextKitの新しい組版機能を理解しておく価値があります

コメント

GitHub Issues · utterances