0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

権限追加で拡張機能が強制無効化される!?Chrome拡張の「オプショナル・パーミッション」運用術

0
Posted at

Chrome拡張機能をリリースし、ユーザー数が増えてきた頃に最もやってはいけない致命的なミスがある。

それは、新機能のために manifest.json の permissions に「警告付きの権限」を気軽に追加してアップデートを公開することだ。

軽い気持ちで topSites や tabs、広いホスト権限を1行追加してストアにアップロードした瞬間、何千人、何万人という既存ユーザーのブラウザで、次の事態が発生する。

Chromeは、インストール時の警告文に載らなかった新しい権限をアップデートが要求した場合、その拡張機能をユーザーが再承認するまで無効化する。すべての権限がこの対象になるわけではなく、storage や alarms のように警告を伴わない権限なら黙って更新される。しかし topSites(「アクセス頻度の高いサイトの読み取り」)や tabs(「閲覧履歴の読み取り」)のように警告文が付く権限を足した瞬間、この仕組みが発動する。

新しいタブを置き換える拡張機能にとって、これは二重に残酷だ。無効化された瞬間、ユーザーの新しいタブは何の説明もなく Chrome 標準の真っ白なページに戻る。ユーザーから見れば「昨日まで便利に使っていた拡張機能が突然消えた」「壊れた」としか映らない。手動で拡張機能の管理画面を開き、権限を再承認してくれるユーザーはごく一部だ。

自作の新しいタブ拡張機能「ZenithTab」で「よく見るサイト」と「最近閉じたタブ」を表示するクイックアクセスウィジェットを実装した際、この罠を ストア公開の直前で 踏みかけた。そのときの顛末と、既存ユーザーのブラウザで1件の強制無効化も起こさず新機能を届けるために構築した「オプショナル・パーミッション(Optional Permissions)」の設計と運用パターンを解説する。


発端:v1.6.0 で permissions に足してしまった topSites と sessions

v1.6.0 でクイックアクセスウィジェットを実装したとき、最初のコミットでは manifest.json の permissions に topSites と sessions をそのまま追加していた。ローカルで「パッケージ化されていない拡張機能を読み込む」で動作確認する限り、何の問題もなく動く。

ストアにアップロードする前に権限まわりを見直していて、topSites がインストール時の警告を伴う権限であることに気づいた。もしこのまま公開していれば、自動更新を受け取った全ユーザーの新しいタブが Chrome 標準に戻っていた。

翌日の v1.6.1 で両権限を optional_permissions へ移し、「ウィジェットを追加した瞬間」に要求する形へ作り直した。コミットメッセージにはこう残している。

topSites 権限はインストール時の警告対象であり、拡張機能のアップデートによって警告を伴う権限が追加されると、Chrome はユーザーが再承諾するまでその拡張機能を強制的に無効化してしまいます。新しいタブを置き換える拡張機能にとって、これはユーザーの新しいタブが何の前触れもなく勝手に Chrome 標準のタブに戻ってしまうことを意味します。(幸いにも、)Chrome ウェブストアへアップロードする直前にこの危険に気づいて防ぐことができました。(和訳)


第2幕:chrome.sessions APIの奇妙な仕様

話はそこで終わらなかった。v1.9.0 の開発中、「最近閉じたタブ」ビューが権限を許可済みでも空のままになる不具合が見つかった(Issue #27)。「よく見るサイト」は表示される。閉じたタブだけが出ない。

chrome.sessions.getRecentlyClosed() は sessions 権限だけで呼び出せる。sessions 配列自体は返ってくるのに、各 tab オブジェクトの url と title が undefined になっていたのだ。

ChromeのAPI仕様書を読み直して、理由が判明した。

Chrome APIの制約:
sessions.getRecentlyClosed() が返す tabs.Tab の url / title / favIconUrl は、tabs 権限(または一致するホスト権限) を持っていない拡張機能には含まれない。

tabs 権限を持たない拡張機能に対しては、プライバシー保護のためにURLとタイトルを伏せた「殻だけのオブジェクト」しか返さない仕様になっていた。ZenithTab 側では URL の安全性チェック if (!tab.url || !isSafeHttpUrl(tab.url) || !tab.sessionId) continue; がすべてのタブを弾き、結果が常に空配列になっていた。


絶望的な二者択一と、第3の道

ここで残酷な二者択一を迫られる。

  1. tabs 権限をあきらめる: 最近閉じたタブの復元機能をボツにする。
  2. permissions に tabs を追加する: 機能は動くが、既存ユーザー全員の拡張機能が強制無効化され、大炎上する。

この地獄を回避するためにChrome拡張機能の仕様に用意されているのが、optional_permissions(オプショナル・パーミッション) という仕組みだ。

インストール時には権限を要求せず、ユーザーがその機能を実際に使おうとしたタイミングで、ブラウザ標準の許可ダイアログを動的に表示してユーザーに承認を求める。

これを使えば、自動アップデート時に拡張機能が無効化される事故を防ぎつつ、必要な人にだけ必要な権限を渡せる。


オプショナル・パーミッションのマニフェスト定義

manifest.json では、常時必要な最小限の権限だけを permissions に残し、機能追加で必要になった強力な権限はすべて optional_permissions へ逃がす。ZenithTab の現行マニフェスト(v1.10.4)はこうなっている。

{
  "manifest_version": 3,
  "permissions": ["storage", "bookmarks", "alarms", "favicon", "geolocation", "unlimitedStorage"],
  "optional_permissions": ["topSites", "sessions", "tabs"],
  "host_permissions": [
    "https://api.open-meteo.com/*",
    "https://geocoding-api.open-meteo.com/*",
    "https://nominatim.openstreetmap.org/*",
    "https://news.google.com/*",
    "https://images.unsplash.com/*"
  ],
  "optional_host_permissions": ["https://*/*"]
}

host_permissions も同じ思想で、天気・地名解決・Google ニュース・Unsplash という「初期状態で使う5つのオリジン」に絞っている。ユーザーが自分で追加する RSS フィードやカレンダーの URL は optional_host_permissions で、そのオリジン1つだけを追加時に要求する。

ここで重要なのは、optional_permissions に登録しただけでは、拡張機能は何の権限も持っていないという点だ。APIを叩く前に、JavaScriptからユーザーに明示的にリクエストを出さなければならない。


ユーザー操作(User Gesture)の制約とリクエストの実装

オプショナル・パーミッションの要求(chrome.permissions.request)には、ブラウザの厳しいセキュリティ制約が課されている。

「クリックやキー入力などのユーザーの直接操作(User Gesture)のハンドラ内から同期的に呼び出さなければならない」 というルールだ。

ページの読み込み時(useEffect や DOMContentLoaded)に勝手にリクエストを呼ぶと、Chromeは This function must be called during a user gesture というエラーを吐いて即座に拒絶する。ユーザーが予期しないタイミングでポップアップを乱発させないための防護柵だ。クリックハンドラ内であっても、await で別の非同期処理を挟んでから呼ぶとジェスチャーの文脈が切れることがあるので、ハンドラの最初に呼ぶのが鉄則になる。

ZenithTabでは、ホスト権限用に作ってあったヘルパーと対にする形で、API 権限用の関数を src/utils/permissions.ts に用意した。

/** 列挙した API 権限がすべて許可済みなら true。拡張外(テスト・dev サーバー)では常に true */
export async function hasApiPermissions(permissions: string[]): Promise<boolean> {
  const api = permissionsApi();
  if (!api) return true;
  try {
    return await api.contains({ permissions });
  } catch {
    return false;
  }
}

/** API 権限を要求する。ユーザー操作の中から同期的に呼ぶこと */
export async function requestApiPermissions(permissions: string[]): Promise<boolean> {
  const api = permissionsApi();
  if (!api) return true;
  try {
    return await api.request({ permissions });
  } catch (error) {
    console.warn('[ZenithTab] Permission request failed:', error);
    return false;
  }
}

そして、src/services/quickAccessService.ts で「どのビューにどの権限が要るか」を1か所に宣言する。

export const QUICK_ACCESS_VIEW_PERMISSIONS: Record<QuickAccessView, string[]> = {
  topSites: ['topSites'],
  recentlyClosed: ['sessions', 'tabs'],
};
export const QUICK_ACCESS_PERMISSIONS = [
  ...QUICK_ACCESS_VIEW_PERMISSIONS.topSites,
  ...QUICK_ACCESS_VIEW_PERMISSIONS.recentlyClosed,
];

export const quickAccessService = {
  hasPermission(view?: QuickAccessView) {
    return hasApiPermissions(view ? QUICK_ACCESS_VIEW_PERMISSIONS[view] : QUICK_ACCESS_PERMISSIONS);
  },
  /** クリックハンドラから呼ぶこと */
  requestPermission(view?: QuickAccessView) {
    return requestApiPermissions(view ? QUICK_ACCESS_VIEW_PERMISSIONS[view] : QUICK_ACCESS_PERMISSIONS);
  },
  // ...
};

プログレッシブ・パーミッション(段階的要求)のUI設計

権限の要求コードが整ったら、次はUI/UXの設計だ。

「権限が足りないから何も表示しない」ではユーザーは何をしていいか分からない。かといって、いきなり大きな警告ダイアログを出すのも心理的ハードルが高い。

ZenithTabでは、機能の文脈に合わせた段階的要求(プログレッシブ・パーミッション) を採用した。

1. ウィジェット追加時のファーストコンタクト

ウィジェットのメタ定義(widgetDefinitions.ts)に optionalPermissions というフィールドを持たせ、ウィジェット追加ダイアログの「追加」ボタンのクリックハンドラの中で、まとめて要求する。

// widgetDefinitions.ts
quickaccess: {
  type: 'quickaccess',
  optionalPermissions: ['topSites', 'sessions', 'tabs'],
  // ...
},

// AddWidgetModal.tsx — 「追加」クリックの中(ユーザー操作の文脈)
const handleAdd = (widget: WidgetDefinition) => {
  if (widget.optionalPermissions?.length) {
    void requestApiPermissions(widget.optionalPermissions);
  }
  addWidget(widget.type);
};

image.png

ここでユーザーが許可すれば、その後の追加操作なしでスムーズに使い始めることができる。ウィジェット自体は権限の結果を待たずに追加する——拒否されてもウィジェットは置かれ、次の案内板が出る。

2. ダイアログをキャンセルしたユーザーへのフォールバック表示

仮にユーザーが最初のダイアログで「許可しない」を押した場合でも、ウィジェットをクラッシュさせてはならない。

ウィジェット内部に案内板(Empty State)を表示し、なぜその権限が必要なのかの理由を明記した上で、「権限を有効にする」ボタンを配置する(QuickAccessWidget.tsx)。

const [needsPermission, setNeedsPermission] = useState(false);

const load = useCallback(async () => {
  if (!(await quickAccessService.hasPermission(view))) {
    setNeedsPermission(true);
    return;
  }
  setNeedsPermission(false);
  // ...データ取得
}, [view]);

// クリックハンドラそのものにしておく: Chrome はユーザー操作の中でしかダイアログを出さない
const handleGrant = async () => {
  if (await quickAccessService.requestPermission(view)) void load();
};

// ...
{needsPermission ? (
  <div className="...">
    <p>{t.widgets.quickaccess.permissionNeeded}</p>
    <button type="button" onClick={handleGrant}>{t.widgets.quickaccess.grantAccess}</button>
  </div>
) : (
  <ItemList items={items} />
)}

image.png

ユーザーが自分の意志でボタンを押した瞬間であれば、Chromeの権限要求ダイアログは確実に表示される。許可された瞬間に load() が走り、何事もなかったかのようにタブ一覧が表示される。

3. ビューごとの独立要求

「よく訪れるサイト一覧(topSites)」と「最近閉じたタブ一覧(sessions + tabs)」をタブ切り替えUIで分けているので、ユーザーが「よく訪れるサイト」しか使わないのであれば、tabs 権限を無理に求める必要はない。

上のコードで hasPermission(view) / requestPermission(view) に 表示中のビュー を渡しているのはそのためだ。v1.6 系で topSites + sessions をすでに許可していた既存ユーザーには、「最近閉じたタブ」ビューを開いたときにだけ、足りない tabs の許可を求める。プライバシーを気にするユーザーにも最小限の権限で安心して使い続けてもらうことができる。


Chrome Web Store審査用の正当性証明

オプショナル・パーミッションを活用することは、ユーザーの離脱防止だけでなく、Chrome Web Storeの審査をスムーズに通すためにも有利に働く。

Chrome Web Storeの審査では、要求している各権限について「なぜ必要か」を説明する欄がある。manifest.json の permissions に常時要求として tabs を書けば、「新しいタブ置き換えにしては権限が強すぎないか」と問われる可能性は高くなる。

ZenithTab では、ストア申請の文面をリポジトリ内の CHROMEWEBSTORE.md で管理しており、tabs については次のように記載している。

tabs (optional_permissions):
クイックアクセスウィジェットの『最近閉じたタブ』表示機能のため、sessions 権限とセットで要求されます。 Chrome の仕様上、この権限が付与されていないと閉じたタブの url や title が除外されてしまうため、権限がないと一覧に何も表示できなくなってしまいます。 あくまで閉じたタブをローカル画面に表示するためだけに利用しており、当拡張機能が現在開いているタブを一覧取得したり読み取ったりすることは一切ありません。

「ChromeのAPI仕様上、tabs 権限がないとタイトルが取れないため技術的に不可欠であること」「現在開いているタブの情報を列挙・読み取りすることは決してしないこと」を、技術的根拠とともに記述する。

「必要最小限の権限しか常時保持せず、強力な権限は機能の文脈でユーザー自身に選択させる」。この姿勢を示すことが、最小特権の原則(Principle of Least Privilege)に最も忠実なアプローチとなる。


既存ユーザーを裏切らない開発を

Chrome拡張機能の個人開発において、ユーザー数はもっとも大切にすべき資産だ。

数ヶ月かけてコツコツ増やしたユーザーが、たった1行の permissions 追加によって一夜にして全員無効化され、二度と戻ってこない——ZenithTab はその一歩手前で踏みとどまった。

拡張機能に新しい機能を追加するときは、まず疑おう。
「この権限は本当に最初から必須なのか?」
「その権限は、インストール時の警告文に新しい行を足すものか?」
「オプショナル・パーミッションとして、使う人にだけ後から渡す形にできないか?」

ひと手間のUI実装とユーザー操作ハンドリングを挟むだけで、ユーザーの信頼と安全なアップデート運用の両方を守り抜くことができる。


さいごに:ZenithTabについて

本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。

ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。

  • 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
  • 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
  • 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備

Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!

ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!

0
1
0

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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?